Security Model
Threat model, guard middleware, PII handling, provenance and the red-team suite.
OpenSmartRoute is an LLM control plane: it decides who answers. That makes its integrity a security property in its own right (Shafran et al., "Rerouting LLM Routers", 2025). This document lists assets, threats, and the controls implemented, mapped to OWASP Top 10 / OWASP LLM Top 10.
Assets
- Routing decisions (integrity, availability)
- Request content (confidentiality – may contain PII / secrets)
- Learner state (integrity – poisoning changes future decisions)
- Configuration & credentials of downstream targets
- Audit trail
Threat model & controls
| # | Threat | OWASP ref | Control | Code |
|---|---|---|---|---|
| T1 | Confounder gadgets – attacker appends token soup to force routing to the expensive model (cost DoS) or to a weaker model (quality attack) | LLM01, LLM04 | Head/tail naturalness analysis; signals computed on cleaned head; optional reject; learned gadget detector (InputGuard(learned=True), trained with osr train --gadget); router-directed sentences ("this is extremely complex", [complexity=1]) are scrubbed before signal extraction | security.InputGuard, security.gadget.GadgetDetector, security.injection.strip_steering, GuardMiddleware |
| T2 | Prompt injection into LLM judge – request text tells the judge which target to pick | LLM01 | Request wrapped in a fenced block; role markers and code fences neutralised; control/zero-width chars stripped; judge output parsed as strict JSON, item by item: non-object items, unparseable / NaN scores and ids not in the candidate list are dropped, scores clipped to [0,1]; judge only enabled below a confidence threshold; judge failures carry zero confidence so the ensemble ignores them | security.sanitize_for_prompt, LLMJudgeStrategy |
| T3 | Policy bypass – PII to a cloud model, EU data to US region, tenant reading another tenant's targets | A01 Broken access control | Policy evaluated before scoring and cannot be outweighed; tenant middleware; allow/deny lists | policy.Policy, TenantMiddleware |
| T4 | Data leakage via logs / cache / feedback | A09 Logging failures, LLM06 | Logs contain hash+length only; cache keys are SHA-256; Redactor masks PII before routing; feedback store records ids, not text | LoggingTelemetry, CacheMiddleware, Redactor, FeedbackStore |
| T5 | Learner poisoning – fake outcomes to steer routing | LLM03 Training-data poisoning | Outcomes only accepted via learn() from your code path (auth is your boundary); graded rewards clipped to [0,1]; Bayesian priors + confidence ramps limit the impact of few samples; Page–Hinkley drift alerts on sudden shifts; state files atomic and hash-addressable; corrupt / incompatible state is quarantined on load instead of being trusted or crashing (AutoLearner.quarantined); federated merge() adds only evidence beyond the prior | AutoLearner, math.estimators, learning.merge_learners |
| T6 | Resource exhaustion – huge prompts, unbounded state | A04 Insecure design / DoS | max_chars/max_tokens guard; every in-memory map is bounded; per-target rate limits and budgets; breaker isolates failing targets | InputGuard, realtime |
| T7 | Path traversal in state store | A01 | Keys hashed to filenames; writes are atomic to a fixed root | FileStateStore |
| T8 | Secrets in config | A02 Cryptographic failures / A05 Misconfig | Targets YAML never contains credentials; load_secret() reads env or *_FILE | security.load_secret |
| T9 | Deserialisation | A08 Software & data integrity | Only JSON / yaml.safe_load; no pickle anywhere | TargetRegistry.from_file, learners |
| T10 | Audit tampering | A09 | Hash-chained audit records; the chain resumes from the last record across restarts (no second genesis) and FileAuditSink.verify(path) re-walks it naming the first edited, removed or unparseable line | FileAuditSink |
| T11 | Supply chain | A06 Vulnerable components | Zero runtime dependencies in core; optional extras pinned by minimum version; CI runs bandit, ruff -S, mypy | pyproject.toml, CI |
| T12 | Jailbreak content reaching a model | LLM01 | jailbreak_risk ≥ 0.5 → only safety-tagged targets or humans are admissible; run_safety_suite() / osr safety regression-tests that safety routing survives paraphrase, gadgets and steering | Policy.check, security.safety |
| T13 | Poisoned catalogue entries – MCP tool descriptions, A2A agent cards or skill files carry instructions to the router or model | LLM01, A08 | Instruction-injection lexicon + learned gadget score on every imported description; poisoned tools dropped at import time and reported; signed MCP manifests (HMAC-SHA256 or Ed25519, freshness window) so only vetted catalogues load | security.injection, adapters.mcp.tools_from_mcp, sign_manifest / verify_manifest |
| T14 | Parameter provenance – a tool result or retrieved document smuggles a value into a state-changing tool call ("send the refund to this account") | LLM01, A01 | OriginPolicy: sensitive parameters of state-changing tools must originate in the user turn; violations raise OriginViolation before execution | security.provenance |
| T15 | Resource amplification in agentic plans – runaway loops, recursive tool calls, token / cost blow-up | A04, LLM10 | Per-task caps on steps, tool calls, depth, tokens, cost and wall-clock enforced as middleware; multi-resource knapsack bandit checks hard meters before commitment and audits every pruned arm | security.limits.ResourceLimiter, ResourceLimitMiddleware, math.bandits.MultiKnapsackBandit |
| T16 | Learner state at rest – snapshots reveal usage patterns or are modified on disk | A02 | AES-256-GCM EncryptedStateStore with key rotation (cryptography extra); versioned store refuses to load unknown schema versions | enterprise.stores.EncryptedStateStore, VersionedStateStore |
| T17 | Tool calls made by the model – a plan offers tools and the executor runs what the model calls: a prompt-injected model could call an offered tool with attacker-chosen arguments | LLM01, LLM07 | Only tool targets that passed the policy filter and the quality floor for this request are offered (tool_slots, tool_quality_floor; OSR_ROUTING_TOOL_SLOTS=0 turns the slot off); calls to tools the router did not offer are handed back, never run; arguments are parsed as JSON and passed as context["tool_arguments"] with the model-only context keys stripped; the PII boundary applies to the tool; rounds are capped (tool_rounds); OriginPolicy and ResourceLimiter (T14, T15) apply to the tool's handler as to any other | execution._offer_tools, execution._tool_request, RoutingSettings |
| T18 | Catalogue and knowledge pulled from the network – marketplace listings synced into a running router, article feeds read by the self-improvement loop | A08, LLM03 | Marketplace: https only (or localhost http), manifests validated as Open Capability Manifests, third-party descriptions risk-scored like any import; MarketplaceSync follows only the kinds / query / slugs you configure. Feeds: https only, byte-capped, stdlib XML parser without external-entity expansion, articles can only make a priced catalogue card eligible as a candidate - they never set a quality prior and never bypass the champion / challenger promotion gates | adapters.marketplace, adapters.websearch.parse_feed, learning.self_improve |
| T19 | Leakage and injection in the answer – a model prints a credential it saw in a file, echoes personal data that was never in the prompt, or relays a poisoned tool result as an instruction to the next agent | LLM02 Insecure output handling, LLM06 | OutputGuard screens every model / agent answer after the handler returns: credential shapes (SECRET_PATTERNS: OpenAI, Anthropic, AWS, GitHub, Slack keys, private-key blocks, JWTs), the input guard's PII entities and inspect_injection on the answer text. flag records the findings (output_guard.flagged event, context["output_guard"]), redact replaces them with typed placeholders, block raises SecurityError (output_guard.blocked); a streamed answer under redact / block is buffered (default) or screened delta by delta through StreamScreen before anything reaches the consumer, so a secret split across chunks never leaves. POST /v1/moderations exposes the same screens without a provider call | security.OutputGuard, security.output.StreamScreen, security.moderate, execution._guard_output, GuardMiddleware(output=), RouterBuilder.with_guard(output=), serve --output-guard |
Secure defaults
- Core has no network access; it never calls a model unless you wire an
LLMJudgeStrategyor a targethandler. - Logging is content-free, and so is tracing: span and event attributes carry ids, numbers, short labels
and a text digest + length (
observability.text_digest), never the request text. The hosted platform reads traces per workspace only (GET /api/v1/trace/{request_id}is 404 for another workspace's id). - The LLM judge is off by default and, when on, only consulted below a confidence threshold.
Redactoris opt-in (GuardMiddleware(redact=True)); when on, the original text is kept only inrequest.context["_pii_map"]for the executor and is never persisted by the SDK.
What OpenSmartRoute does not do
- Authenticate callers. Put it behind your API gateway / service mesh.
- Encrypt state at rest by default. Use disk/volume encryption or wrap your store in
enterprise.stores.EncryptedStateStore. - Guarantee the downstream target's safety. Routing to a "safe" target is a policy you write.
Threat-model delta: 0.3 -> 0.4
What changed for operators upgrading, and which residual risks remain:
| Area | 0.3 | 0.4 | Residual risk |
|---|---|---|---|
| Gadget / steering (T1) | heuristic head/tail analysis | + learned detector, + steering-sentence scrub before signal extraction | detector is trained on synthetic gadgets; re-train on your traffic (osr train --gadget) |
| Judge injection (T2) | fenced prompt, strict JSON | + per-item filtering, id allow-list, NaN / type guards, zero-confidence failures | a compromised judge can still bias ranks among legitimate ids; keep escalate_llm_judge_below <= 0.5 |
| Catalogue poisoning (T13) | none | injection lexicon + gadget score on imported descriptions; signed manifests | lexicon is English-centric; sign manifests for anything you did not author |
| Parameter provenance (T14) | none | OriginPolicy for state-changing tools | policy is per tool / parameter; unlisted tools are not checked |
| Resource amplification (T15) | per-target rate limits and budgets | + per-task caps (steps, calls, depth, tokens, cost, wall-clock), knapsack meters before commitment | caps are enforced in the routing process; a handler that ignores the returned budget can still spend |
| Learner integrity (T5) | atomic writes | + quarantine of corrupt / incompatible state, versioned schema, federated merge adds evidence only | a valid-looking poisoned snapshot is indistinguishable from real learning; restrict write access to the store |
| Audit (T10) | hash chain per process | + chain continuity across restarts, verify() | the log file itself must be write-once at the OS level to make verification meaningful |
| State at rest (T16) | plain JSON | + AES-256-GCM store with rotation | key management is yours (load_secret) |
| Deployment | none | non-root container, hardened Helm chart (read-only rootfs, drop all capabilities, NetworkPolicy) | the chart exposes /route unauthenticated inside the cluster; front it with your gateway |
External review of this delta is outstanding; External review pack is the pack a reviewer works from and its review log is where the report is linked.
Hardening checklist for operators
- Run
osr servebehind TLS termination and a token (osr serve --require-auth --token ...); give dashboards and CI read-only tokens (<token>:ro- they cannot execute, spend or teach the learners). - Turn the controls on for the served router - no Python needed:
osr serve --guard --redact-pii --output-guard redact --health --audit /state/audit.jsonl --origin-policy --limits --state-store redis://...(orOSR_SERVER_GUARD=1,OSR_SERVER_REDACT_PII=1,OSR_SERVER_OUTPUT_GUARD=redact,OSR_SERVER_HEALTH=1,OSR_SERVER_AUDIT_PATH,OSR_SERVER_ORIGIN_POLICY=1,OSR_SERVER_RESOURCE_LIMITS=1,OSR_SERVER_STATE_STORE,OSR_SERVER_STATE_KEY_ENVin the Helm chart'senv). In code the same isenterprise.harden(router, guard=True, redact_pii=True, ...)orRouterBuilder.with_guard(redact=True) .with_origin_policy().with_limits(). - Set
TargetConstraints.pii_allowed=Falseanddata_boundaryon every cloud target. - Treat live web context as untrusted input.
--web-grounding(WebGroundingMiddleware) puts search hits in front of the model for requests about current facts; every title and snippet is scored with the injection lexicon and dropped atOSR_SLM_PAGE_RISK_THRESHOLD(LiveContext.dropped,web.groundedevents carry the count), the query is the request's first sentence only, fetches are https-only and byte-capped, and the hits never reach a tool call - keep--output-guardon so a page that slips through cannot exfiltrate through the answer either. - Configure
HealthRegistry.configure(rate_per_s=…, budget_limit=…)per expensive target. - Ship
MetricsTelemetryto your monitoring; alert onAutoLearner.driftedand breaker opens. - Rotate and restrict access to the state directory; back it up (it is your learned policy).
- Alert on
AutoLearner.quarantined(corrupt state was replaced) and runFileAuditSink.verify()on the audit log from a read-only replica. - Sign MCP manifests you import (
osr mcp-manifest) and fail closed onverify_manifesterrors. - Run
osr safety --learned-guardin CI so safety routing regressions block a release. - Pin the package version; watch the security advisories on this repo.
Reporting
Something wrong or out of date on this page? Tell us - the page is named for you, so a one-line note is enough.