detect

package
v0.48.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 10, 2026 License: MIT Imports: 7 Imported by: 0

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

View Source
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.

View Source
const (
	ThreatLevelDangerous = "dangerous" // any hard signal → auto-quarantine
	ThreatLevelWarning   = "warning"   // soft-only → review
	ThreatLevelInfo      = "info"
)

Threat levels — user-facing severity, mirrors scanner.ThreatLevel*.

View Source
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.

View Source
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

func CapEvidence(s string) string

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

func ClampConfidence(c float64) float64

ClampConfidence constrains a confidence to the valid [0,1] range.

func Normalize

func Normalize(s string) string

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

type Coverage struct {
	ChecksRun      int
	ChecksFailed   int
	FailedCheckIDs []string
}

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 NewEngine

func NewEngine(opts Options) *Engine

NewEngine builds an Engine from Options.

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

func ClassifyPosition(text string, matchStart int) Position

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.

func (Position) Discount

func (p Position) Discount() float64

Discount returns the confidence multiplier for a position.

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 Result

type Result struct {
	Findings []Finding
	Coverage Coverage
}

Result is the output of a scan.

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
)

func (Tier) String

func (t Tier) String() string

String renders the tier for logs and evidence.

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).

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL