Skip to content

feat: optional evidence-only detection backend for prompt injection (Python + Rust) - #3014

Merged
Imran Siddique (imran-siddique) merged 4 commits into
microsoft:mainfrom
kerberosmansour:feat/knn-optional-detection-backend
Jun 15, 2026
Merged

Imran Siddique (imran-siddique) merged 4 commits into
microsoft:mainfrom
kerberosmansour:feat/knn-optional-detection-backend

Conversation

@kerberosmansour

Copy link
Copy Markdown
Contributor

Summary

Connects the existing local kNN/embedding signal (prompt_injection_embedding) to the PromptInjectionDetector pipeline as an optional, default-off, evidence-only backend, in both the Python (agent-os) and Rust (agentmesh) SDKs. This is the "connect the signal to the detection pipeline as an optional backend" gap noted in #2918; content normalization (RFC #2957 / #2991) remains upstream. Design is recorded in ADR 0031 and follows the pluggable-backend pattern of ADR-0015.

What changes

  • A small pluggable backend interface — Python Protocol, Rust trait (DetectionEvidenceBackend) — with a stable name and evaluate(text).
  • PromptInjectionDetector consults registered backends after the deterministic verdict and appends their EvidenceSignals to a new additive DetectionResult.evidence field.
  • EmbeddingSignalBackend adapts the existing kNN signal to the interface.
  • Registration is explicit (Python evidence_backends=, Rust with_evidence_backends(...)); default-off.

Invariants (tested)

  • Evidence-only: evidence never affects is_injection / threat_level / injection_type / confidence / matched_patterns, and never blocks (blocks == false). Verdict fields are byte-identical with a backend on vs off.
  • Default-off: with no backend registered, detect() output is unchanged.
  • Non-breaking: Rust DetectionResult is #[non_exhaustive]; the Python field has a default. No new default dependency — the embedding model/runtime is optional, used only when a backend is enabled.
  • Audit-safe: EvidenceSignal carries a static backend id, a score, and a static error code — never raw input.

Tests

  • Python agent-os: full prompt_injection suite green (new: verdict-parity, evidence-never-blocks, backend-failure isolation, no-raw-text, adapter default-off / fake-embedder).
  • Rust agentmesh: 50 integration + 17 lib unit tests pass (6 new evidence-backend cases).

Notes

Draft — opening for design review of the interface and the evidence-only invariant before finalising. Related: #2918, #2957, #2991; docs/benchmarks/prompt-injection-methodology.md.

@github-actions

github-actions Bot commented Jun 13, 2026 •

Copy link
Copy Markdown

PR Review Summary

Check Status Details
🔍 Code Review ⚠️ Missing No current-run comment
🛡️ Security Scan ⚠️ Missing No current-run comment
🔄 Breaking Changes ⚠️ Missing No current-run comment
📝 Docs Sync ⚠️ Missing No current-run comment
🧪 Test Coverage ⚠️ Missing No current-run comment

Verdict: ⚠️ AI review incomplete; ready for human review

AI review comments are untrusted advisory output. The summary reports workflow-generated completion status only, not model-authored pass/fail claims.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests size/XL Extra large PR (500+ lines) labels Jun 13, 2026
@github-actions

Copy link
Copy Markdown

🟡 Contributor Check: MEDIUM

Check Result
Profile MEDIUM
Credential MEDIUM
Overall MEDIUM

Automated check by AGT Contributor Check.

@github-actions github-actions Bot added the needs-review:MEDIUM Contributor check flagged MEDIUM risk label Jun 13, 2026
…njection

Add a pluggable, default-off evidence backend to the prompt-injection detector,
following the pluggable-backend pattern of ADR-0015. When a backend is
registered via evidence_backends, its advisory EvidenceSignal is appended to
DetectionResult.evidence after the deterministic verdict is computed. Evidence
never influences is_injection/threat_level/injection_type/confidence/
matched_patterns and never blocks on its own.

EmbeddingSignalBackend adapts the existing prompt_injection_embedding kNN signal
to this interface, connecting it to the detection pipeline (see microsoft#2918); content
normalization (microsoft#2957) remains upstream of any backend.

With no backend registered the detector output is unchanged. A backend that
raises is recorded as a static error code and never breaks detection; evidence
carries no raw input text.

Tests: verdict-parity (backend on vs off), evidence-never-blocks, backend-failure
isolation, no-raw-text, and adapter default-off / fake-embedder cases.
…injection

Mirror the agent-os wiring in the Rust SDK, following ADR-0015's pluggable-backend
pattern. PromptInjectionDetector gains an optional, default-off
with_evidence_backends(...); each backend's advisory EvidenceSignal is appended to
DetectionResult.evidence after the deterministic verdict is computed. Evidence
never influences is_injection/threat_level/injection_type/confidence/
matched_patterns and never blocks on its own.

DetectionResult is marked #[non_exhaustive] so the additive `evidence` field is
non-breaking; EmbeddingSignalBackend adapts the existing
prompt_injection_embedding kNN signal to the backend trait (see microsoft#2918). With no
backend registered the detector output is unchanged.

Tests: 6 new integration cases (verdict parity backend on vs off,
evidence-never-blocks, adapter default-off / fake-embedder). agentmesh
prompt_injection: 50 integration + 17 lib unit tests pass.
…export

Document the optional, default-off evidence-backend design (pluggable ADR-0015
pattern, evidence-only, normalization upstream) and re-export EvidenceSignal /
DetectionEvidenceBackend / EmbeddingSignalBackend from the agentmesh crate root
for parity with the other prompt-injection types.

Refs microsoft#2918 microsoft#2957.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good work on the core design — the Protocol/trait interface is clean, the default-off guarantee holds, and the verdict-parity tests are thorough. Three confirmed bugs and five plausible issues below. The three confirmed bugs need fixing before merge.

[BUG] Rust collect_evidence has no panic guard

cosine() uses assert_eq!(a.len(), b.len(), ...) which panics on dimension mismatch and propagates through EmbeddingSignalBackend::evaluate -> collect_evidence -> detect(), bypassing the unwrap_or_else (which catches Err, not panics). Python wraps every backend call in except Exception; Rust has nothing equivalent. Fix: wrap backend.evaluate(text) in std::panic::catch_unwind and record EvidenceSignal::with_error(backend.name(), "backend_error") on panic, symmetric with the Python path.

[BUG] Rust detect_without_audit silently skips evidence collection

detect_without_audit calls detect_impl directly with no call to collect_evidence. Any caller on this path always gets evidence: Vec::new() with no log, no error, no indication. Either call collect_evidence there too, or add a doc comment explicitly stating evidence is intentionally omitted on the audit-free path.

[BUG] REST API _detection_result_to_response does not include the new evidence field

_detection_result_to_response in server/app.py manually maps onto DetectionResponse without evidence. API consumers will never see evidence signals even with backends configured — silent data loss. Add evidence to both DetectionResponse and the extractor.

[PLAUSIBLE] EvidenceSignal.score can be nan or inf

No finite-value check before constructing EvidenceSignal. If a pluggable embedder returns vectors with nan/inf, those propagate unchecked (nan > threshold is False; inf > threshold is True). Validate math.isfinite(score) or document the embedder contract.

[PLAUSIBLE] kNN margin scores in the audit log are an evasion oracle

Evidence with raw scores lands in every audit record. An operator with audit log access can use per-request score feedback to iteratively tune a prompt toward lower margins. Consider bucketing scores (high/medium/low) in the audit record, with raw scores only in aggregated telemetry.

[PLAUSIBLE] EvidenceSignal.blocks is a convention, not a runtime invariant

frozen=True prevents mutation but not EvidenceSignal(backend="x", blocks=True) at construction. _collect_evidence appends signals without validating signal.blocks. The Rust constructor already enforces blocks: false; the Python path should match — add a __post_init__ guard or make blocks a ClassVar[bool] = False.

[PLAUSIBLE] signal: object annotation defeats static analysis

Use TYPE_CHECKING to annotate signal: EmbeddingSignal. The signal: object annotation forces # type: ignore[attr-defined] on the .score() call and means mypy silently accepts EmbeddingSignalBackend(42).

[PLAUSIBLE, minor] Rust struct/impl bound mismatch

EmbeddingSignalBackend<E: Embedder> struct vs E: Embedder + Send + Sync on the trait impl. Move the Send + Sync constraint to the struct definition so contributors get a clear error at construction, not at the coercion site.

…ence backend

Resolve the three confirmed bugs and five plausible issues from review.

Confirmed bugs:
- Rust: wrap backend.evaluate() in catch_unwind so a panicking backend
  (e.g. the embedding signal's cosine() asserting on a dimension mismatch)
  is recorded as a static backend_error instead of unwinding through detect()
  and bypassing the Err-only fail-closed path. Symmetric with Python's
  except-guard.
- Rust: detect_without_audit now also runs collect_evidence, so the
  audit-free path is consistent with detect() (only the audit write is skipped).
- REST API: DetectionResponse gains an evidence field and
  _detection_result_to_response maps it, so configured backends actually
  surface through /api/v1/detect/injection.

Plausible issues:
- EvidenceSignal rejects non-finite (NaN/inf) scores: Python raises in
  __post_init__; Rust drops to a non_finite_score error code.
- Evidence-only invariant is enforced, not conventional: blocks=true is
  rejected (Python) / forced false (Rust) at the boundary.
- Audit oracle: raw evidence scores are stripped from the durable audit copy
  so the margin cannot be used as a per-request evasion oracle; the live
  DetectionResult keeps raw scores for telemetry.
- Python EmbeddingSignalBackend annotates signal: EmbeddingSignal (via
  TYPE_CHECKING) instead of object, dropping the type: ignore.
- Rust EmbeddingSignalBackend moves the Send + Sync bound onto the struct so
  a non-Send/Sync embedder fails at construction, not at the coercion site.

Tests: +7 Python (prompt_injection), +2 Python (server REST evidence),
+3 Rust integration (panic guard, blocks/NaN coercion, audit score strip),
+1 Rust unit (detect_without_audit evidence). Full Python prompt_injection +
server suites and cargo test --release --workspace all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kerberosmansour

Copy link
Copy Markdown
Contributor Author

Imran Siddique (@imran-siddique) thanks for the thorough review — all three confirmed bugs and the five plausible issues are addressed in 6c78fa6.

Confirmed bugs

  • Rust collect_evidence panic guard — evaluate() is now wrapped in std::panic::catch_unwind; a panicking backend (e.g. cosine()'s assert_eq! on dimension mismatch) is recorded as a static backend_error, symmetric with the Python except path.
  • Rust detect_without_audit skipped evidence — it now runs collect_evidence too, so the audit-free path is consistent with detect() (only the audit-log write is skipped).
  • REST evidence field — added to DetectionResponse and mapped in _detection_result_to_response; backends now surface through /api/v1/detect/injection.

Plausible issues

  • NaN/inf score — rejected at the EvidenceSignal boundary: Python raises in __post_init__; Rust drops the value to a non_finite_score error code.
  • blocks runtime invariant — enforced now, not conventional: blocks=True raises (Python) / is forced false (Rust) before a signal can reach the result or audit.
  • kNN margin audit oracle — raw evidence scores are stripped from the durable audit copy, so the margin can't be used as a per-request evasion oracle. I went with full omission rather than high/med/low bucketing: bucketing needs backend-specific thresholds baked into a backend-agnostic type, whereas omission is threshold-free and strictly safer. The live DetectionResult returned to the caller keeps raw scores for in-process telemetry/aggregation.
  • signal: object — annotated signal: EmbeddingSignal via TYPE_CHECKING; the # type: ignore[attr-defined] is gone and mypy resolves .score.
  • Rust struct/impl bound — Send + Sync moved onto the EmbeddingSignalBackend<E> struct, so a non-Send/Sync embedder fails at construction rather than at the coercion site.

Tests: +7 Python (prompt_injection), +2 Python (server REST evidence), +3 Rust integration (panic guard, blocks/NaN coercion, audit score strip), +1 Rust unit (detect_without_audit evidence). Full Python prompt_injection + server suites and cargo test --release --workspace all green.

Out of scope (non-blocking, noted for follow-up) — a few pre-existing items I deliberately left alone so this stays a review-fix commit:

  1. EmbeddingSignal::score() / cosine() still panics on dimension mismatch when called directly (Python's _cosine raises a catchable ValueError). Contained here by the catch_unwind guard; the underlying API would ideally return a Result.
  2. Base64 decoded-keyword coverage differs between Python (10 keywords) and Rust (6 phrases) — a recall difference worth confirming.
  3. Rust custom patterns scan raw (un-normalized) text while built-in rules scan normalized text — a possible normalization-evasion gap for operator-defined rules.

Happy to file 1–2 as separate issues if you'd like.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fixes hold up: EvidenceSignal.__post_init__ now enforces both invariants at construction time (blocks=False raised, non-finite score rejected), so a misbehaving backend cannot smuggle an enforcing signal past the detector. The @runtime_checkable Protocol and EmbeddingSignalBackend adapter are clean. Approving.

@imran-siddique
Imran Siddique (imran-siddique) merged commit 8a1cc12 into microsoft:main Jun 15, 2026
10 of 11 checks passed
jlaportebot (jlaportebot) pushed a commit to jlaportebot/agent-governance-toolkit that referenced this pull request Jun 17, 2026
…Python + Rust) (microsoft#3014)

* feat(agent-os): optional evidence-only detection backend for prompt injection

Add a pluggable, default-off evidence backend to the prompt-injection detector,
following the pluggable-backend pattern of ADR-0015. When a backend is
registered via evidence_backends, its advisory EvidenceSignal is appended to
DetectionResult.evidence after the deterministic verdict is computed. Evidence
never influences is_injection/threat_level/injection_type/confidence/
matched_patterns and never blocks on its own.

EmbeddingSignalBackend adapts the existing prompt_injection_embedding kNN signal
to this interface, connecting it to the detection pipeline (see microsoft#2918); content
normalization (microsoft#2957) remains upstream of any backend.

With no backend registered the detector output is unchanged. A backend that
raises is recorded as a static error code and never breaks detection; evidence
carries no raw input text.

Tests: verdict-parity (backend on vs off), evidence-never-blocks, backend-failure
isolation, no-raw-text, and adapter default-off / fake-embedder cases.

* feat(agentmesh): optional evidence-only detection backend for prompt injection

Mirror the agent-os wiring in the Rust SDK, following ADR-0015's pluggable-backend
pattern. PromptInjectionDetector gains an optional, default-off
with_evidence_backends(...); each backend's advisory EvidenceSignal is appended to
DetectionResult.evidence after the deterministic verdict is computed. Evidence
never influences is_injection/threat_level/injection_type/confidence/
matched_patterns and never blocks on its own.

DetectionResult is marked #[non_exhaustive] so the additive `evidence` field is
non-breaking; EmbeddingSignalBackend adapts the existing
prompt_injection_embedding kNN signal to the backend trait (see microsoft#2918). With no
backend registered the detector output is unchanged.

Tests: 6 new integration cases (verdict parity backend on vs off,
evidence-never-blocks, adapter default-off / fake-embedder). agentmesh
prompt_injection: 50 integration + 17 lib unit tests pass.

* docs(adr): optional embedding evidence backend (ADR 0031) + crate re-export

Document the optional, default-off evidence-backend design (pluggable ADR-0015
pattern, evidence-only, normalization upstream) and re-export EvidenceSignal /
DetectionEvidenceBackend / EmbeddingSignalBackend from the agentmesh crate root
for parity with the other prompt-injection types.

Refs microsoft#2918 microsoft#2957.

* fix(prompt-injection): address PR microsoft#3014 review — harden evidence backend

Resolve the three confirmed bugs and five plausible issues from review.

Confirmed bugs:
- Rust: wrap backend.evaluate() in catch_unwind so a panicking backend
  (e.g. the embedding signal's cosine() asserting on a dimension mismatch)
  is recorded as a static backend_error instead of unwinding through detect()
  and bypassing the Err-only fail-closed path. Symmetric with Python's
  except-guard.
- Rust: detect_without_audit now also runs collect_evidence, so the
  audit-free path is consistent with detect() (only the audit write is skipped).
- REST API: DetectionResponse gains an evidence field and
  _detection_result_to_response maps it, so configured backends actually
  surface through /api/v1/detect/injection.

Plausible issues:
- EvidenceSignal rejects non-finite (NaN/inf) scores: Python raises in
  __post_init__; Rust drops to a non_finite_score error code.
- Evidence-only invariant is enforced, not conventional: blocks=true is
  rejected (Python) / forced false (Rust) at the boundary.
- Audit oracle: raw evidence scores are stripped from the durable audit copy
  so the margin cannot be used as a per-request evasion oracle; the live
  DetectionResult keeps raw scores for telemetry.
- Python EmbeddingSignalBackend annotates signal: EmbeddingSignal (via
  TYPE_CHECKING) instead of object, dropping the type: ignore.
- Rust EmbeddingSignalBackend moves the Send + Sync bound onto the struct so
  a non-Send/Sync embedder fails at construction, not at the coercion site.

Tests: +7 Python (prompt_injection), +2 Python (server REST evidence),
+3 Rust integration (panic guard, blocks/NaN coercion, audit score strip),
+1 Rust unit (detect_without_audit evidence). Full Python prompt_injection +
server suites and cargo test --release --workspace all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: jlaportebot <jlaportebot@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation needs-review:MEDIUM Contributor check flagged MEDIUM risk size/XL Extra large PR (500+ lines) tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants