Documentation
¶
Overview ¶
Package detect implements the deterministic, offline MCP tool-scanner v2 (Spec 076).
Contract (see specs/076-deterministic-tool-scanner/contracts/detect-engine.md):
Offline: this package performs NO I/O. It imports no networking (net, net/http), no process execution (os/exec), no filesystem access (os), and no HTTP/Docker client. Detection runs purely over in-memory tool definitions supplied by the caller. The offline guarantee is enforced by the standing import-guard test (imports_test.go) and backs FR-001.
Deterministic: identical input (a RegistryView) yields byte-identical output, including finding and signal ordering. No maps are iterated for output ordering; no clocks or randomness are consulted.
Total: every registered Check.Inspect call is run under recover(). A check that panics or errors is isolated, counted in Coverage, and never aborts the scan. A degraded scan still returns its other findings, the same way the existing scanner surfaces scanners_failed.
The engine aggregates per-tool Signals into the existing internal/security/scanner.ScanFinding type (now additively carrying Confidence and Signals), so all CLI/REST/MCP entry points keep their shapes.
Index ¶
Constants ¶
const ( SeverityCritical = "critical" SeverityHigh = "high" SeverityMedium = "medium" SeverityLow = "low" SeverityInfo = "info" )
Severity levels — string values mirror internal/security/scanner so a Finding maps onto scanner.ScanFinding without translation (the scanner wiring copies these strings verbatim). detect cannot import scanner (import cycle), so the vocabulary is mirrored here, not aliased.
const ( ThreatLevelDangerous = "dangerous" // any hard signal → auto-quarantine ThreatLevelWarning = "warning" // soft-only → review ThreatLevelInfo = "info" )
Threat levels — user-facing severity, mirrors scanner.ThreatLevel*.
const ( ThreatToolPoisoning = "tool_poisoning" ThreatPromptInjection = "prompt_injection" ThreatRugPull = "rug_pull" ThreatExfiltration = "exfiltration" ThreatMaliciousCode = "malicious_code" ThreatUncategorized = "uncategorized" )
Threat types — the report vocabulary, mirrors scanner.Threat* plus the exfiltration category from the Spec-076 data model.
const MaxEvidenceLen = 200
MaxEvidenceLen caps the rune length of rendered evidence before an ellipsis is appended, keeping reports bounded regardless of input size.
Variables ¶
This section is empty.
Functions ¶
func CapEvidence ¶
CapEvidence makes a string safe to render in CLI/HTML/JSON reports: control runes and zero-width/bidi format runes are escaped to a visible \uXXXX form (revealed, never silently dropped — the whole point is to expose smuggling), and the result is truncated to MaxEvidenceLen runes with an ellipsis.
func ClampConfidence ¶
ClampConfidence constrains a confidence to the valid [0,1] range.
func Normalize ¶
Normalize canonicalizes free text for semantic matching by the soft checks (FR-009). It applies, in order: NFKC, removal of zero-width / bidi / BOM format runes (Unicode category Cf), lowercasing, contraction expansion (so "don't disclose" and "do not tell" both yield a "do not …" form), whitespace collapse + trim, and light stemming.
It is pure and deterministic. The HARD unicode check (FR-007) deliberately runs on RAW text instead, so stripping format runes here never hides an attack — it only stabilizes phrase matching.
Types ¶
type Check ¶
type Check interface {
ID() string
Inspect(tool ToolView, reg RegistryView) []Signal
}
Check inspects one tool against the whole registry snapshot and emits zero or more signals. Implementations MUST be pure, total, and deterministic — the engine wraps every Inspect in recover(), but a well-behaved check never panics and returns its signals in a stable order.
type Coverage ¶
Coverage records how complete a scan was: a check whose Inspect panicked or errored is recovered, counted here, and never aborts the scan — mirroring the existing scanners_failed degradation path.
type Engine ¶
type Engine struct {
// contains filtered or unexported fields
}
Engine runs all registered checks over a registry snapshot and aggregates per-tool signals into findings. Pure aside from the recover() isolation that keeps a misbehaving check from aborting the scan.
func (*Engine) Scan ¶
func (e *Engine) Scan(reg RegistryView) Result
Scan inspects every tool in the snapshot. The RegistryView is built once per scan (indexes + NormalizedText) if the caller passed an unindexed view, then shared with every check. A check that panics is isolated; the scan still returns its other findings. Output (findings and ordering) is deterministic for identical input.
type Finding ¶
type Finding struct {
RuleID string
Scanner string
ThreatType string
ThreatLevel string
Severity string
Category string
Title string
Description string
Location string
Evidence string
Confidence float64
Signals []string
}
Finding is the per-tool aggregation output. It is self-contained (no scanner import) and converts 1:1 to scanner.ScanFinding in the scanner wiring (T012); the additive Confidence/Signals fields already exist on ScanFinding (T004).
type Options ¶
type Options struct {
// Checks are run, in this order, against every tool. Order is part of the
// determinism contract.
Checks []Check
// ScannerID is stamped onto every finding's Scanner field. Defaults to
// "tpa-descriptions" when empty.
ScannerID string
}
Options configures an Engine.
type Position ¶
type Position int
Position classifies where a phrase match sits in the surrounding text, so the checks can tell a genuine embedded instruction ("ignore previous instructions") apart from a tool that merely DESCRIBES such phrases ("analyzes prompts that ignore previous instructions"). This is the core false-positive control for legitimate security tooling (FR-009, US2), and the deciding factor between the hard/soft tiers for a matched directive.
It is a three-way scale, not a binary one, because the two false-positive pressures pull in opposite directions (Spec 077 US1, Codex round-2):
- A bare label prefix ("Prompt:", "Message:") must NOT neutralize a clear imperative — that framing is exactly what an attacker uses to smuggle a directive (finding A, recall bypass). Such matches stay PositionInstruction and hard-block.
- A tool that DESCRIBES an injection — a relative clause ("prompts that ignore…") or an analytical verb governing the phrase ("returns text: ignore…") — must not hard-block a benign tool (finding B). Such matches are PositionDescriptive: the hard tier is discounted below its floor (HARD→SOFT), but the soft tier still surfaces the match for review, so a real injection wearing descriptive clothing never fully vanishes.
- Genuine quotation/illustration ("such as", "e.g.", surrounding quotes) is PositionExample and most heavily discounted — that framing is the weakest injection signal. It is NOT, however, silently dropped: a check built for recall (phrase.injection) re-floors an already-MATCHED phrase to the soft emit floor so a quoted/illustrated injection surfaces as review-only rather than vanishing entirely (Spec 077 US1, Codex round-3 — the "never fully suppress a matched injection" invariant that closes the recurring silent-bypass class). "Quiet" for a legitimate quoting scanner now means soft/review, never invisible.
Position framing is decided per SENTENCE, not per description: a cue in a PRIOR sentence must not discount an imperative that begins a LATER one. Both the analytical-verb heuristic and the "example"/"such as" word cues are sentence-scoped (only the inline abbreviations "e.g."/"i.e." and quote runs, which carry their own internal periods, are matched across the whole window). Without this, a benign lead ("Example output format. Ignore all previous instructions…") would misclassify the following injection as an example and bypass the hard tier (Codex round-3 finding #1).
const ( // PositionInstruction is an imperative/instruction-position match; full // confidence is kept. This is also where a bare "label:" prefix lands — a // label alone does not discount a clear imperative (finding A). PositionInstruction Position = iota // PositionDescriptive is a match the tool DESCRIBES rather than issues: a // relative clause ("… that ignore …") or an analytical verb governing the // phrase. Discounted enough to drop a hard match below its emit floor // (HARD→SOFT) while a soft match still surfaces for review — no total // suppression, so a genuine injection cannot bypass detection by adopting // descriptive phrasing (finding B without reopening finding A). PositionDescriptive // PositionExample is a quotation/illustration-position match (after "such // as"/"e.g." or inside quotes); most heavily discounted. On its own it clears // neither per-check floor, so a SOFT-only check (directive.imperative) stays // quiet — its US2 false-positive control. A recall-oriented HARD check // (phrase.injection) instead re-floors an already-matched phrase to the soft // emit floor, so the match surfaces for review rather than vanishing (the // "never fully suppress" invariant). PositionExample )
func ClassifyPosition ¶
ClassifyPosition decides whether the match starting at byte offset matchStart in text is in instruction-, descriptive-, or example-position. text may be raw or normalized; matching is case-insensitive on the preceding window.
type RegistryView ¶
type RegistryView struct {
Tools []ToolView
ToolsByName map[string][]ToolView // name → tools with that name (collision detection)
ToolNames map[string]struct{} // fast membership for cross-tool references
}
RegistryView is a read-only snapshot of every server's current tools, built once per scan and passed to every check so cross-tool checks (shadowing) can reason about collisions and references.
func NewRegistryView ¶
func NewRegistryView(tools []ToolView) RegistryView
NewRegistryView builds the cross-tool indexes and precomputes each tool's NormalizedText (once). It preserves input ordering in Tools and in each ToolsByName slice, which keeps downstream findings deterministic.
type Signal ¶
type Signal struct {
CheckID string // stable identifier, e.g. "unicode.hidden"
Tier Tier // hard (auto-quarantine) vs soft (review)
ThreatType string // maps to ScanFinding.ThreatType
Confidence float64 // 0.0–1.0; already position-discounted for soft signals
Evidence string // render-safe; for payload.decoded this is the decoded content
Detail string // short human explanation
}
Signal is the output of a single Check for a single tool.
type Tier ¶
type Tier int
Tier ranks a signal's contribution to the risk decision.
const ( // TierSoft signals raise a finding for review but never auto-quarantine on // their own. Zero value, so an uninitialized Signal is conservatively soft. TierSoft Tier = iota // TierHard signals contribute to auto-quarantine; checks emitting them are // built for near-zero false positives. TierHard )
type ToolView ¶
type ToolView struct {
Server string
Name string
Description string // raw, un-normalized
InputSchema json.RawMessage
OutputSchema json.RawMessage
NormalizedText string // precomputed Normalize(description + schema text)
}
ToolView is a read-only projection of one tool supplied to checks. An empty description/schema is valid input and yields zero signals (no error).