Skip to content

Commit c233c24

Browse files
authored
feat(security): Spec 076 T1 — detect engine foundation (signal pipeline) (#769)
* docs(security): Spec 076 — deterministic offline tool-scanner v2 (spec/plan/tasks) Related to scanner-reliability rebuild (in-process detector v2). ## Changes - specs/076-deterministic-tool-scanner: spec.md, plan.md, tasks.md, research.md, data-model.md, contracts/detect-engine.md, quickstart.md, checklists/requirements.md - Signal-pipeline design: 6 deterministic offline checks, two-tier enforcement, CI-gated corpus eval (recall>=0.90, FP<=5%) - update-agent-context appended detect-package tech context to CLAUDE.md ## Testing - Docs only; no code change. Implementation tracked in tasks.md (T001-T024). * feat(security): Spec 076 T1 — detect engine foundation (signal pipeline) Foundational scaffolding for the deterministic, offline tool-scanner v2. No checks yet — this lands the package, core types, and the engine/aggregate skeleton every check (US1–US4) builds on. - T001/T002 new internal/security/detect package + doc.go and a standing offline import-guard test (no net/os/exec/http/docker; FR-001). The guard also forbids importing scanner to lock the no-cycle layering. - T003 core types signal.go: Tier, Signal, Check, ToolView, RegistryView (with ToolsByName/ToolNames indexes + NewRegistryView builder), plus ClampConfidence and CapEvidence (render-safe, escapes control/zero-width). - T004 additive Confidence float64 + Signals []string on ScanFinding (omitempty; existing consumers byte-unaffected) + types_test round-trip. - T005 normalize.go: NFKC, zero-width/bidi strip, lowercase, contraction expansion (don't->do not), whitespace collapse, light stemming. - T006 position.go: instruction-vs-example classifier (discounts after "such as"/"e.g."/"detects"/quotes) to hold down false positives. - T007 engine.go: runs checks under recover(), builds RegistryView once, Coverage{ChecksRun,ChecksFailed,FailedCheckIDs}; determinism + totality. - T008 aggregate.go: signals -> Finding; hard->dangerous (critical when escalated, else high); soft-only severity by distinct CheckID count (1->low/2->medium/3+->high); independent confidences add, capped at 1.0. Design note: to avoid a detect<->scanner import cycle (T012 wires scanner-> detect), detect is self-contained — Result.Findings is []detect.Finding, converted 1:1 to scanner.ScanFinding by the scanner layer. Contract doc updated to match. TDD throughout (failing _test.go first). go test -race green; golangci-lint v2 clean; swagger no-diff. Related #NNN
1 parent 5ed2c30 commit c233c24

23 files changed

Lines changed: 1799 additions & 0 deletions

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,3 +155,6 @@ tail -f ~/Library/Logs/mcpproxy/main.log # main log (macOS; Linux: ~/.mcpproxy/
155155
- Config changes: update both storage and file system; the file watcher hot-reloads.
156156
- **macOS tray dev** (build / replace / verify with `mcpproxy-ui-test`): [docs/development/macos-tray.md](docs/development/macos-tray.md).
157157
- **Windows installer**: [docs/github-actions-windows-wix-research.md](docs/github-actions-windows-wix-research.md). **Prerelease** (`next` branch + `v*-rc.*` tags, opt-in, off stable channels): [docs/prerelease-builds.md](docs/prerelease-builds.md).
158+
159+
## Recent Changes
160+
- 076-deterministic-tool-scanner: Added Go 1.24 + stdlib only for detection (`unicode`, `unicode/utf8`, `encoding/base64`, `encoding/hex`, `regexp`); `golang.org/x/text/unicode/norm` (already an indirect dep via x/text) for NFKC; existing `internal/security/patterns/`, `internal/security/scanner/`, `internal/runtime/tool_quarantine.go`. No new third-party dependency.
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package detect
2+
3+
import "fmt"
4+
5+
// Severity levels — string values mirror internal/security/scanner so a Finding
6+
// maps onto scanner.ScanFinding without translation (the scanner wiring copies
7+
// these strings verbatim). detect cannot import scanner (import cycle), so the
8+
// vocabulary is mirrored here, not aliased.
9+
const (
10+
SeverityCritical = "critical"
11+
SeverityHigh = "high"
12+
SeverityMedium = "medium"
13+
SeverityLow = "low"
14+
SeverityInfo = "info"
15+
)
16+
17+
// Threat levels — user-facing severity, mirrors scanner.ThreatLevel*.
18+
const (
19+
ThreatLevelDangerous = "dangerous" // any hard signal → auto-quarantine
20+
ThreatLevelWarning = "warning" // soft-only → review
21+
ThreatLevelInfo = "info"
22+
)
23+
24+
// Threat types — the report vocabulary, mirrors scanner.Threat* plus the
25+
// exfiltration category from the Spec-076 data model.
26+
const (
27+
ThreatToolPoisoning = "tool_poisoning"
28+
ThreatPromptInjection = "prompt_injection"
29+
ThreatRugPull = "rug_pull"
30+
ThreatExfiltration = "exfiltration"
31+
ThreatMaliciousCode = "malicious_code"
32+
ThreatUncategorized = "uncategorized"
33+
)
34+
35+
// criticalConfidence is the hard-signal confidence at/above which a dangerous
36+
// finding is rated critical rather than high. Escalating checks (≥3 unicode
37+
// classes, decoded shell payloads) emit near-1.0 confidence.
38+
const criticalConfidence = 0.9
39+
40+
// Finding is the per-tool aggregation output. It is self-contained (no scanner
41+
// import) and converts 1:1 to scanner.ScanFinding in the scanner wiring (T012);
42+
// the additive Confidence/Signals fields already exist on ScanFinding (T004).
43+
type Finding struct {
44+
RuleID string
45+
Scanner string
46+
ThreatType string
47+
ThreatLevel string
48+
Severity string
49+
Category string
50+
Title string
51+
Description string
52+
Location string
53+
Evidence string
54+
Confidence float64
55+
Signals []string
56+
}
57+
58+
// aggregate combines every signal emitted for one tool into a single Finding,
59+
// applying the Spec-076 tier and severity semantics (FR-005, FR-006, FR-010).
60+
// It returns ok=false when there are no signals. It is deterministic: output
61+
// depends only on the signal slice order.
62+
func aggregate(tool ToolView, signals []Signal, scannerID string) (Finding, bool) {
63+
if len(signals) == 0 {
64+
return Finding{}, false
65+
}
66+
67+
// Distinct CheckIDs in first-seen order, plus combined confidence and the
68+
// primary (highest-tier, first-seen) signal.
69+
seen := make(map[string]struct{}, len(signals))
70+
var ids []string
71+
var confSum float64
72+
var primary Signal
73+
haveHard := false
74+
maxHardConf := 0.0
75+
distinctSoft := make(map[string]struct{})
76+
77+
for i, s := range signals {
78+
confSum += ClampConfidence(s.Confidence)
79+
if _, dup := seen[s.CheckID]; !dup {
80+
seen[s.CheckID] = struct{}{}
81+
ids = append(ids, s.CheckID)
82+
}
83+
switch s.Tier {
84+
case TierHard:
85+
if !haveHard {
86+
primary = s // first hard signal wins as primary
87+
haveHard = true
88+
}
89+
if c := ClampConfidence(s.Confidence); c > maxHardConf {
90+
maxHardConf = c
91+
}
92+
case TierSoft:
93+
distinctSoft[s.CheckID] = struct{}{}
94+
}
95+
if i == 0 && !haveHard {
96+
primary = s // fall back to first signal until a hard one appears
97+
}
98+
}
99+
if !haveHard {
100+
primary = signals[0]
101+
}
102+
103+
f := Finding{
104+
RuleID: "detect." + primary.CheckID,
105+
Scanner: scannerID,
106+
ThreatType: primary.ThreatType,
107+
Category: primary.ThreatType,
108+
Location: fmt.Sprintf("%s:%s", tool.Server, tool.Name),
109+
Title: findingTitle(primary, tool),
110+
Description: primary.Detail,
111+
Evidence: primary.Evidence,
112+
Confidence: ClampConfidence(confSum),
113+
Signals: ids,
114+
}
115+
116+
if haveHard {
117+
f.ThreatLevel = ThreatLevelDangerous
118+
if maxHardConf >= criticalConfidence {
119+
f.Severity = SeverityCritical
120+
} else {
121+
f.Severity = SeverityHigh
122+
}
123+
} else {
124+
f.ThreatLevel = ThreatLevelWarning
125+
f.Severity = softSeverity(len(distinctSoft))
126+
}
127+
return f, true
128+
}
129+
130+
// softSeverity maps the count of distinct soft CheckIDs to a severity:
131+
// 1→low, 2→medium, 3+→high.
132+
func softSeverity(distinct int) string {
133+
switch {
134+
case distinct >= 3:
135+
return SeverityHigh
136+
case distinct == 2:
137+
return SeverityMedium
138+
default:
139+
return SeverityLow
140+
}
141+
}
142+
143+
func findingTitle(primary Signal, tool ToolView) string {
144+
name := tool.Name
145+
if name == "" {
146+
name = "tool"
147+
}
148+
return fmt.Sprintf("%s flagged on %s", primary.CheckID, name)
149+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package detect
2+
3+
import "testing"
4+
5+
func soft(id string, conf float64) Signal {
6+
return Signal{CheckID: id, Tier: TierSoft, ThreatType: ThreatToolPoisoning, Confidence: conf, Detail: id}
7+
}
8+
func hard(id string, conf float64) Signal {
9+
return Signal{CheckID: id, Tier: TierHard, ThreatType: ThreatPromptInjection, Confidence: conf, Detail: id}
10+
}
11+
12+
func TestAggregateNoSignals(t *testing.T) {
13+
if _, ok := aggregate(ToolView{Name: "x"}, nil, "s"); ok {
14+
t.Fatal("no signals must yield ok=false")
15+
}
16+
}
17+
18+
func TestAggregateHardIsDangerous(t *testing.T) {
19+
tool := ToolView{Server: "srv", Name: "calc"}
20+
f, ok := aggregate(tool, []Signal{hard("unicode.hidden", 0.95)}, "tpa-descriptions")
21+
if !ok {
22+
t.Fatal("expected a finding")
23+
}
24+
if f.ThreatLevel != ThreatLevelDangerous {
25+
t.Errorf("ThreatLevel = %q, want dangerous", f.ThreatLevel)
26+
}
27+
if f.Severity != SeverityCritical {
28+
t.Errorf("Severity = %q, want critical (escalated hard)", f.Severity)
29+
}
30+
if f.ThreatType != ThreatPromptInjection {
31+
t.Errorf("ThreatType = %q, want prompt_injection", f.ThreatType)
32+
}
33+
if f.Scanner != "tpa-descriptions" {
34+
t.Errorf("Scanner = %q", f.Scanner)
35+
}
36+
if f.Location != "srv:calc" {
37+
t.Errorf("Location = %q, want srv:calc", f.Location)
38+
}
39+
if len(f.Signals) != 1 || f.Signals[0] != "unicode.hidden" {
40+
t.Errorf("Signals = %v", f.Signals)
41+
}
42+
}
43+
44+
func TestAggregateHardNonEscalatedIsHigh(t *testing.T) {
45+
f, _ := aggregate(ToolView{Name: "t"}, []Signal{hard("shadowing.cross_server", 0.6)}, "s")
46+
if f.Severity != SeverityHigh {
47+
t.Errorf("Severity = %q, want high (non-escalated hard)", f.Severity)
48+
}
49+
if f.ThreatLevel != ThreatLevelDangerous {
50+
t.Errorf("ThreatLevel = %q, want dangerous", f.ThreatLevel)
51+
}
52+
}
53+
54+
func TestAggregateSoftSeverityLadder(t *testing.T) {
55+
cases := []struct {
56+
name string
57+
sigs []Signal
58+
want string
59+
}{
60+
{"one→low", []Signal{soft("a", 0.4)}, SeverityLow},
61+
{"two→medium", []Signal{soft("a", 0.4), soft("b", 0.3)}, SeverityMedium},
62+
{"three→high", []Signal{soft("a", 0.3), soft("b", 0.3), soft("c", 0.3)}, SeverityHigh},
63+
{"dupes count once", []Signal{soft("a", 0.2), soft("a", 0.2)}, SeverityLow},
64+
}
65+
for _, tc := range cases {
66+
t.Run(tc.name, func(t *testing.T) {
67+
f, ok := aggregate(ToolView{Name: "t"}, tc.sigs, "s")
68+
if !ok {
69+
t.Fatal("expected finding")
70+
}
71+
if f.Severity != tc.want {
72+
t.Errorf("Severity = %q, want %q", f.Severity, tc.want)
73+
}
74+
if f.ThreatLevel != ThreatLevelWarning {
75+
t.Errorf("soft-only ThreatLevel = %q, want warning", f.ThreatLevel)
76+
}
77+
})
78+
}
79+
}
80+
81+
func TestAggregateConsensusRaisesConfidence(t *testing.T) {
82+
single, _ := aggregate(ToolView{Name: "t"}, []Signal{soft("a", 0.5)}, "s")
83+
double, _ := aggregate(ToolView{Name: "t"}, []Signal{soft("a", 0.5), soft("b", 0.4)}, "s")
84+
if !(double.Confidence > single.Confidence) {
85+
t.Errorf("consensus confidence %v not greater than single %v", double.Confidence, single.Confidence)
86+
}
87+
if single.Confidence != 0.5 {
88+
t.Errorf("single confidence = %v, want 0.5", single.Confidence)
89+
}
90+
// Independent signals add, capped at 1.0.
91+
capped, _ := aggregate(ToolView{Name: "t"}, []Signal{soft("a", 0.7), soft("b", 0.8)}, "s")
92+
if capped.Confidence != 1.0 {
93+
t.Errorf("capped confidence = %v, want 1.0", capped.Confidence)
94+
}
95+
}
96+
97+
func TestAggregateDistinctSignalsList(t *testing.T) {
98+
f, _ := aggregate(ToolView{Name: "t"}, []Signal{soft("b", 0.2), soft("a", 0.2), soft("b", 0.2)}, "s")
99+
// First-seen order, deduped.
100+
if len(f.Signals) != 2 || f.Signals[0] != "b" || f.Signals[1] != "a" {
101+
t.Errorf("Signals = %v, want [b a]", f.Signals)
102+
}
103+
}

internal/security/detect/doc.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Package detect implements the deterministic, offline MCP tool-scanner v2
2+
// (Spec 076).
3+
//
4+
// Contract (see specs/076-deterministic-tool-scanner/contracts/detect-engine.md):
5+
//
6+
// - Offline: this package performs NO I/O. It imports no networking
7+
// (net, net/http), no process execution (os/exec), no filesystem access
8+
// (os), and no HTTP/Docker client. Detection runs purely over in-memory
9+
// tool definitions supplied by the caller. The offline guarantee is
10+
// enforced by the standing import-guard test (imports_test.go) and backs
11+
// FR-001.
12+
//
13+
// - Deterministic: identical input (a RegistryView) yields byte-identical
14+
// output, including finding and signal ordering. No maps are iterated for
15+
// output ordering; no clocks or randomness are consulted.
16+
//
17+
// - Total: every registered Check.Inspect call is run under recover(). A
18+
// check that panics or errors is isolated, counted in Coverage, and never
19+
// aborts the scan. A degraded scan still returns its other findings, the
20+
// same way the existing scanner surfaces scanners_failed.
21+
//
22+
// The engine aggregates per-tool Signals into the existing
23+
// internal/security/scanner.ScanFinding type (now additively carrying
24+
// Confidence and Signals), so all CLI/REST/MCP entry points keep their shapes.
25+
package detect

internal/security/detect/engine.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package detect
2+
3+
import "sort"
4+
5+
// defaultScannerID is the bundled in-process scanner the engine attributes its
6+
// findings to, matching the existing tpa-descriptions analyzer it replaces.
7+
const defaultScannerID = "tpa-descriptions"
8+
9+
// Options configures an Engine.
10+
type Options struct {
11+
// Checks are run, in this order, against every tool. Order is part of the
12+
// determinism contract.
13+
Checks []Check
14+
// ScannerID is stamped onto every finding's Scanner field. Defaults to
15+
// "tpa-descriptions" when empty.
16+
ScannerID string
17+
}
18+
19+
// Engine runs all registered checks over a registry snapshot and aggregates
20+
// per-tool signals into findings. Pure aside from the recover() isolation that
21+
// keeps a misbehaving check from aborting the scan.
22+
type Engine struct {
23+
checks []Check
24+
scannerID string
25+
}
26+
27+
// NewEngine builds an Engine from Options.
28+
func NewEngine(opts Options) *Engine {
29+
id := opts.ScannerID
30+
if id == "" {
31+
id = defaultScannerID
32+
}
33+
return &Engine{checks: opts.Checks, scannerID: id}
34+
}
35+
36+
// Coverage records how complete a scan was: a check whose Inspect panicked or
37+
// errored is recovered, counted here, and never aborts the scan — mirroring the
38+
// existing scanners_failed degradation path.
39+
type Coverage struct {
40+
ChecksRun int
41+
ChecksFailed int
42+
FailedCheckIDs []string
43+
}
44+
45+
// Result is the output of a scan.
46+
type Result struct {
47+
Findings []Finding
48+
Coverage Coverage
49+
}
50+
51+
// Scan inspects every tool in the snapshot. The RegistryView is built once per
52+
// scan (indexes + NormalizedText) if the caller passed an unindexed view, then
53+
// shared with every check. A check that panics is isolated; the scan still
54+
// returns its other findings. Output (findings and ordering) is deterministic
55+
// for identical input.
56+
func (e *Engine) Scan(reg RegistryView) Result {
57+
if reg.ToolsByName == nil {
58+
reg = NewRegistryView(reg.Tools)
59+
}
60+
61+
failed := make(map[string]struct{})
62+
findings := make([]Finding, 0, len(reg.Tools))
63+
64+
for i := range reg.Tools {
65+
tool := reg.Tools[i]
66+
var toolSignals []Signal
67+
for _, c := range e.checks {
68+
sigs, panicked := safeInspect(c, tool, reg)
69+
if panicked {
70+
failed[c.ID()] = struct{}{}
71+
continue
72+
}
73+
toolSignals = append(toolSignals, sigs...)
74+
}
75+
if f, ok := aggregate(tool, toolSignals, e.scannerID); ok {
76+
findings = append(findings, f)
77+
}
78+
}
79+
80+
failedIDs := make([]string, 0, len(failed))
81+
for id := range failed {
82+
failedIDs = append(failedIDs, id)
83+
}
84+
sort.Strings(failedIDs)
85+
86+
return Result{
87+
Findings: findings,
88+
Coverage: Coverage{
89+
ChecksRun: len(e.checks) - len(failedIDs),
90+
ChecksFailed: len(failedIDs),
91+
FailedCheckIDs: failedIDs,
92+
},
93+
}
94+
}
95+
96+
// safeInspect runs one check under recover() so a panic is contained. A check
97+
// that panics yields no signals and panicked=true.
98+
func safeInspect(c Check, tool ToolView, reg RegistryView) (sigs []Signal, panicked bool) {
99+
defer func() {
100+
if r := recover(); r != nil {
101+
sigs = nil
102+
panicked = true
103+
}
104+
}()
105+
return c.Inspect(tool, reg), false
106+
}

0 commit comments

Comments
 (0)