Scout Plugin Hybrid Routing
Routes tasks to the appropriate model across cloud, on-device, and org-hosted endpoints based on sensitivity, role, and difficulty, with provable egress control to keep sensitive content within trust boundaries.
README
Hybrid Contextual Inference — Microsoft Scout Plugin
Route each task to the right model across a hybrid stack — Scout's hosted cloud models, on-device runtimes, and org-hosted endpoints — and keep sensitive content inside the trust boundary it belongs in.
A Scout port of smfworks/hermes-plugin-hybrid-routing, rebuilt for
Scout's extension model and hardened around the thing that actually makes
hybrid inference worth doing: provable egress control.
The three-signal design, the difficulty heuristics and the config schema originate upstream (MIT, © 2026 SMF Works). See ATTRIBUTION.md for exactly what was carried over and what is new here.
What it does
Classifies every task on three signals, then picks a model:
| Signal | Values | Drives |
|---|---|---|
| sensitivity | normal / confidential / restricted |
which trust boundary the content may cross |
| role | coding, research, creative, strategy, vision, general | model specialization |
| difficulty | simple / standard / hard | fast / balanced / strong tier |
The primary session model never changes, so prompt caching survives. Tasks that need a different model are delegated to a Scout subagent, or executed against a local / org-hosted endpoint.
Egress classes
This is the core concept, and the main departure from the Hermes original.
There, "local" is a naming convention inside an opaque provider/model string.
Here every model reference declares where inference physically happens:
| Reference | Egress class | Executed via |
|---|---|---|
scout/cloud-strong |
cloud-public |
Scout's task tool |
org/tenant/gpt-4o |
org-tenant |
OpenAI-compatible HTTP |
local/ollama/qwen3:8b |
on-device |
OpenAI-compatible HTTP |
Sensitivity sets a ceiling, and both model selection and the fallback chain are filtered by it:
normal → cloud-public permitted
confidential → org-tenant or tighter
restricted → on-device only
If no configured model satisfies the ceiling, the router blocks. It never degrades to a less-trusted model.
Differences from the Hermes original
Three defects were found by running the upstream engine before porting. Each
has a named regression test in tests/test_hybrid_routing.py.
| # | Severity | Bug | Fix |
|---|---|---|---|
| 1 | critical | Sensitive content with no local model configured falls through to the balanced cloud tier (router.py:253-255), and the hardcoded default ollama-cloud/glm-5.2 is a cloud endpoint. The feature fails open. |
Fails closed — returns status: blocked with no model. |
| 2 | high | All tier models are appended to the fallback chain after the sensitivity branch (router.py:270-273), so a sensitive decision carries cloud models as fallbacks. |
Fallback chains are egress-filtered; a restricted decision contains only on-device candidates. |
| 3 | medium | Role cues match as bare substrings, so the cue test fires on "contest", "greatest", "latest" and misroutes ordinary prose to the coding model. |
Cues match on word boundaries. |
| 4 | medium | simple_cues is the only pattern set compiled without re.IGNORECASE (router.py:133), so capitalized greetings miss the fast tier and land on the paid balanced tier. |
All cue sets are case-insensitive. |
Line references are pinned to upstream commit 88198cf. All four were reported
upstream and are being addressed — see ATTRIBUTION.md.
Plus: the upstream run_tests() evaluates against whichever config is loaded,
so its expectations depend on the user's own model choices — with a plausible
hybrid config it self-reports 6/9. Here the self-test pins its own reference
config, so a failure always means the engine changed.
Second-pass findings (v1.1)
A self-review after v1.0.0 found five more bugs — in this code, not upstream. All 86 v1.0.0 tests passed while every one of them was live, because the tests were written by the author of the code and inherited its blind spots.
| # | Severity | Bug | Fix |
|---|---|---|---|
| 1 | critical | A reference's egress came only from its scheme prefix, while the actual network destination came from the backend's base_url. The two were never reconciled, so local/x/model pointing at a backend declared cloud-public was routed as on-device and validate() returned clean. Restricted content would have been POSTed to a public endpoint. |
resolve_egress() reconciles scheme, backend-declared egress and URL, taking the least trusted of the three |
| 2 | critical | A local/ backend whose base_url was a remote host was still treated as on-device. Nothing checked that "local" meant localhost. |
an on-device claim now requires a loopback address, else it degrades to org-tenant |
| 3 | high | Audit signals truncated a match to 12 characters — but the SSN pattern matches exactly 11, so SSNs and short MRNs were echoed verbatim into the decision record. The v1 test used a long secret and passed. | signals now report the match's shape (###-##-####), masking every alphanumeric |
| 4 | medium | policy.max_egress was compared literally, so On-Device, ondevice or any typo silently disabled the cap — a fail-open on the one setting whose entire job is locking things down. |
normalized case/whitespace; unrecognized values fail closed to on-device and are reported by validate() |
| 5 | medium | A model reference naming an undefined backend validated clean and routed ok, failing only at execution time. |
undefined backends resolve to cloud-public and are flagged |
The lesson generalizes: bug 1 is the same class of failure as the upstream fail-open — a trust decision made from a value that does not actually control where the bytes go. Fixing someone else's version of a bug is no protection against writing your own.
Each fix has a regression test, verified by mutation testing: reverting
resolve_egress to the v1 behaviour fails 5 tests, and reverting _redact
fails the short-secret cases.
Third-pass findings (v1.2)
An independent reviewer found eight more. The theme this time: routing itself was sound, but nothing enforced it at the point of transmission, and several ways of silently deleting a control went unreported.
| # | Severity | Bug | Fix |
|---|---|---|---|
| 1 | high | route_infer accepted any backend, model and prompt with no classification at all. A caller could hand a restricted prompt straight to a cloud-configured backend and never consult the router — making the entire egress model decorative. |
authorize_inference() re-classifies at send time and refuses when the backend's resolved egress exceeds the ceiling; enforced in both the CLI and the MCP tool |
| 2 | high | On-device traffic could escape through an HTTP proxy: urlopen honours $HTTP_PROXY and does not exclude localhost unless $NO_PROXY says so. A proxy would receive the full restricted prompt. |
loopback requests use an empty ProxyHandler and refuse redirects |
| 3 | medium | A backend with no egress: key at all silently inherited the scheme's claim, so org/tenant/m pointing at a public API was accepted for confidential content. Backend.from_config separately defaulted to on-device. |
a missing or unreadable egress resolves to cloud-public; the dataclass default is now the least-trusted class |
| 4 | medium | Confidential prompts and Authorization headers could travel over cleartext http:// to a non-loopback host, and it validated clean. |
non-loopback cleartext degrades to cloud-public and is reported |
| 5 | medium | except re.error: continue silently discarded an invalid sensitivity pattern. A typo'd protection rule vanished while the config still claimed the content was covered. |
compile failures are collected and surfaced by validate() with the rule index |
| 6 | medium | Unknown config keys were ignored, so sensitivty: deleted every detection pattern and max_egres: removed the global cap — both validating clean. |
known-key checking on top-level and nested sections |
| 7 | medium | Signal masking replaced alphanumerics but kept punctuation verbatim, so a symbol-only credential (secret=+-*/) was echoed intact. |
every non-space character is masked |
| 8 | low | Malformed YAML, bad UTF-8 and non-numeric timeouts escaped as tracebacks. | translated into ConfigError / BackendError |
Fourth-pass findings (v1.3.1)
Both defects sit in the one payment-card rule carried over from the Hermes
original, and both were still present upstream at the time of writing
(hybrid_contextual_routing/data/routing_config.yaml:131). The old rule was
"\b(?:\d[ -]?){13,16}\b".
| # | Severity | Bug | Fix |
|---|---|---|---|
| 1 | high | The {13,16} ceiling plus \b on both ends meant a contiguous run longer than 16 digits offered no position where the rule could start, so 17/18/19-digit PANs matched nothing at all — silently, not truncated. ISO/IEC 7812 permits PANs up to 19 digits, and 19-digit Visa (VPay) and UnionPay cards are in issue. A real card number reached a cloud model with nothing raised. |
range widened to 13–19 via "\b(?:\d[ -]?){12,18}\d\b"; the trailing \d also stops the match swallowing a trailing separator |
| 2 | medium | No value check, so any 13–16 digit run was classified restricted: ISBNs, order references, and columns of figures. Because a separator is allowed after every digit, nine two-digit numbers read as one run. Restricted content is blocked when no on-device model is configured, so ordinary documents stopped work. | the rule declares validator: luhn in the config; a match is only honoured if its digits satisfy the Luhn checksum |
Pattern entries therefore accept a mapping as well as a plain string, so the value check stays visible in the config rather than hidden in Python:
restricted_patterns:
- "\\b\\d{3}-\\d{2}-\\d{4}\\b" # plain regex, unchanged
- pattern: "\\b(?:\\d[ -]?){12,18}\\d\\b"
validator: luhn # only match if Luhn-valid
An unknown validator name is reported by validate() and the rule is kept
matching rather than dropped, consistent with third-pass finding 5: a
protection rule that silently vanishes is worse than one that over-fires.
Luhn is a checksum, not proof of a card: roughly one in ten random digit runs of a given length passes it. It removes the bulk of the false positives, not all of them.
Provenance
Content read from an M365 surface is treated as business content by default, even when the text carries no marker. How much that should matter is deployment-specific, so it is configurable:
provenance:
mode: floor # floor (default) | advisory | off
default_level: confidential
sources: # optional per-source overrides
transcript: restricted
calendar: normal
| Mode | Behaviour |
|---|---|
floor |
Anything from an M365 surface is raised to default_level, regardless of content. The default. |
advisory |
The source is recorded in the decision signals but does not raise the level. Labels and content patterns still apply in full. |
off |
Provenance is ignored entirely. |
sources entries win over mode in both directions — they can exempt a
surface the mode would floor, or floor one it would ignore. Set a source to
normal to exempt it.
Which to pick. floor is right for regulated environments where any
tenant-sourced content needs containment. advisory suits an individual whose
M365 traffic is mostly routine: a blanket floor sends every email summary to
the sensitivity model — typically the smallest one available — and the quality
drop tends to make people disable the feature outright, which protects nothing.
Under advisory the label and pattern signals are untouched, so a document
labelled Highly Confidential or an email containing a key still routes to the
restricted model.
An unreadable mode, default_level or source override fails closed to
floor / confidential and is reported by status.
Limitations — read this before trusting it
Classification happens after the session model has already read the content.
This plugin routes execution. If Scout's cloud session model has already
received a confidential email in its context, calling route_classify on it
afterwards cannot un-disclose it — the decision only governs where the
follow-on work runs. Genuine prevention would require classification as
host-side middleware ahead of the first model invocation, which is outside what
a plugin can reach. Treat this as a control over delegation and execution, not
as a guarantee that the primary model never sees sensitive text.
org-tenant is asserted, not verified. Nothing proves an endpoint you
declare as tenant-hosted actually is. The router checks the URL is HTTPS and
non-loopback, and that on-device claims resolve to loopback — it cannot verify
tenancy. That declaration is yours to get right.
Detection is pattern-based. It will miss sensitive content that carries no
marker, no label and no M365 provenance. Pass --label and --source whenever
you have them; they are far stronger signals than the text scan.
Added for Scout
- MIP sensitivity labels — pass
--label "Highly Confidential"and the label drives routing directly. Checked most-restrictive-first, so "Highly Confidential" is not swallowed by the "Confidential" substring. - M365 provenance —
--source email|teams|sharepoint|calendar|transcriptapplies aconfidentialfloor by default. Content read out of the tenant is business content even when the text looks benign. Tunable via theprovenanceconfig section (floor/advisory/off, plus per-source overrides) — see Provenance. - Real local execution — Hermes delegates model execution to its own
provider layer. Scout's
tasktool hosts only Scout's cloud models, so this plugin ships an OpenAI-compatible client covering Ollama, LM Studio, Foundry Local, llama.cpp, vLLM and Azure AI Foundry. - Backend discovery —
probereports what is reachable, including known local runtime ports that are running but unconfigured. reasoning_effortpassed through per tier and role.- Config validation —
statusflags arestricted_modelthat is not actually on-device, and any reference it cannot parse. - Global policy cap —
policy.max_egresscaps all routing regardless of content, for an air-gapped or high-compliance profile. - Graded sensitivity — Hermes has binary normal/sensitive, which cannot express "internal business content, tenant-hosted model is fine". That distinction comes up constantly in Microsoft 365 work.
Install
pip install -r requirements.txt
python -m hybrid_routing init # writes ~/.scout/hybrid_routing/routing_config.yaml
python -m hybrid_routing probe # see which local runtimes are already up
Edit the config to fill in your models, then:
python -m hybrid_routing status # validates and reports problems
python -m hybrid_routing test # engine self-test
See setup.md for the skill and MCP server registration.
Usage
python -m hybrid_routing classify "Debug the import logic in this module"
python -m hybrid_routing classify --label "Highly Confidential" --source email "Summarize this thread"
python -m hybrid_routing infer --backend ollama --model qwen3:8b --prompt-file task.txt
Add --json for machine-readable output. Exit codes: 0 routed, 1 blocked,
2 not configured, 3 error.
Example — sensitive M365 content
$ python -m hybrid_routing classify --label "Highly Confidential" --source email \
"Summarize this thread and list open questions"
sensitivity : restricted
egress ceiling: on-device
MODEL : local/ollama/qwen3:8b
channel : openai-http
delegate : YES
reason: Content classified 'restricted' -> routed to 'local/ollama/qwen3:8b'
(on-device); egress ceiling 'on-device' enforced; 5 cloud/less-trusted
model(s) excluded
signals:
- MIP label 'Highly Confidential' -> restricted
- M365 provenance (email) -> confidential floor
run: python -m hybrid_routing infer --backend ollama --model qwen3:8b --prompt-file <file>
Example — the same content with a cloud-only config
ROUTING BLOCKED
MODEL : (none selected)
reason: BLOCKED: content classified 'restricted' requires a model at or inside
the 'on-device' boundary, and none is configured. 5 configured model(s)
were rejected because their egress class exceeds the 'on-device'
boundary. Routing to an available cloud model would send this content
outside the required boundary.
excluded (outside ceiling):
x scout/cloud-balanced — egress 'cloud-public' exceeds ceiling 'on-device'
Exit code 1. This is the case the upstream plugin silently sends to the cloud.
Tools (via MCP)
Registering the MCP server puts these in Scout's tool list on every turn:
| Tool | Purpose |
|---|---|
route_classify |
classify a task and get a routing decision |
route_status |
show configuration and validation problems |
route_test |
run the engine self-test |
route_probe |
report reachable local / org backends |
route_infer |
run a prompt on a local or org backend |
Architecture
hybrid_routing/
├── egress.py egress classes, model reference parsing ← the trust model
├── classify.py sensitivity / role / difficulty, MIP labels, provenance
├── router.py selection + egress filtering + fail-closed blocking
├── backends.py OpenAI-compatible client, discovery (stdlib only)
├── config.py config resolution and validation
├── selftest.py engine self-test against a pinned reference config
├── cli.py command line interface
└── mcp_server.py MCP stdio server (JSON-RPC, no dependencies)
data/routing_config.yaml shipped default — every model field blank
skill/ Scout skill package
tests/ 164 tests
Design notes
Why blank by default. A router that guesses is a router that eventually sends something somewhere you did not intend. Nothing routes until you name a model. Unconfigured is reported, not silently defaulted.
Why unknown schemes raise. An unrecognized reference could be local or cloud. Guessing wrong on that single question is the entire failure mode this plugin exists to prevent, so it refuses to guess.
Why local models always delegate. skip_for_tier: [fast] can only apply to
Scout-hosted models. A local model is unreachable from the primary session, so
it is always executed out of band regardless of tier.
Why signals are redacted. Classification signals name the pattern that matched and echo at most 12 characters of it, so a decision record is auditable without reprinting the secret that triggered it.
Testing
python -m pytest # 164 tests
License
MIT — © 2026 Michael Gannotti. Derivative of smfworks/hermes-plugin-hybrid-routing,
MIT © 2026 SMF Works. See LICENSE and ATTRIBUTION.md.
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。