endorouter 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. endorouter-0.1.0/LICENSE +176 -0
  2. endorouter-0.1.0/NOTICE +4 -0
  3. endorouter-0.1.0/PKG-INFO +153 -0
  4. endorouter-0.1.0/README.md +131 -0
  5. endorouter-0.1.0/pyproject.toml +39 -0
  6. endorouter-0.1.0/setup.cfg +4 -0
  7. endorouter-0.1.0/src/endorouter/__init__.py +10 -0
  8. endorouter-0.1.0/src/endorouter/audit.py +41 -0
  9. endorouter-0.1.0/src/endorouter/classifier.py +88 -0
  10. endorouter-0.1.0/src/endorouter/cli.py +193 -0
  11. endorouter-0.1.0/src/endorouter/config.py +211 -0
  12. endorouter-0.1.0/src/endorouter/detectors.py +526 -0
  13. endorouter-0.1.0/src/endorouter/discover.py +320 -0
  14. endorouter-0.1.0/src/endorouter/example.yaml +26 -0
  15. endorouter-0.1.0/src/endorouter/labels.py +42 -0
  16. endorouter-0.1.0/src/endorouter/leakbench/__init__.py +1 -0
  17. endorouter-0.1.0/src/endorouter/leakbench/cases-boundary.jsonl +14 -0
  18. endorouter-0.1.0/src/endorouter/leakbench/cases-hard.jsonl +23 -0
  19. endorouter-0.1.0/src/endorouter/leakbench/cases.jsonl +29 -0
  20. endorouter-0.1.0/src/endorouter/leakbench/runner.py +399 -0
  21. endorouter-0.1.0/src/endorouter/policy.py +203 -0
  22. endorouter-0.1.0/src/endorouter/router.py +254 -0
  23. endorouter-0.1.0/src/endorouter/server.py +182 -0
  24. endorouter-0.1.0/src/endorouter/validate.py +148 -0
  25. endorouter-0.1.0/src/endorouter.egg-info/PKG-INFO +153 -0
  26. endorouter-0.1.0/src/endorouter.egg-info/SOURCES.txt +40 -0
  27. endorouter-0.1.0/src/endorouter.egg-info/dependency_links.txt +1 -0
  28. endorouter-0.1.0/src/endorouter.egg-info/entry_points.txt +2 -0
  29. endorouter-0.1.0/src/endorouter.egg-info/requires.txt +12 -0
  30. endorouter-0.1.0/src/endorouter.egg-info/top_level.txt +1 -0
  31. endorouter-0.1.0/tests/test_core.py +208 -0
  32. endorouter-0.1.0/tests/test_discover.py +57 -0
  33. endorouter-0.1.0/tests/test_leakbench.py +307 -0
  34. endorouter-0.1.0/tests/test_review_1.py +197 -0
  35. endorouter-0.1.0/tests/test_review_10.py +278 -0
  36. endorouter-0.1.0/tests/test_review_11.py +159 -0
  37. endorouter-0.1.0/tests/test_review_12.py +315 -0
  38. endorouter-0.1.0/tests/test_review_2.py +132 -0
  39. endorouter-0.1.0/tests/test_review_3.py +53 -0
  40. endorouter-0.1.0/tests/test_review_3_to_9.py +481 -0
  41. endorouter-0.1.0/tests/test_router.py +143 -0
  42. endorouter-0.1.0/tests/test_secret_shape.py +36 -0
@@ -0,0 +1,176 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
@@ -0,0 +1,4 @@
1
+ EndoRouter
2
+ Copyright 2026 J. I. Ashley Consulting LLC
3
+
4
+ Licensed under the Apache License, Version 2.0 (see LICENSE).
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: endorouter
3
+ Version: 0.1.0
4
+ Summary: A model router that decides where a prompt is allowed to go before it decides which model is best. Default local; cloud only when provenance says public. Every decision audited.
5
+ Author-email: "J. I. Ashley Consulting LLC" <hello@smtry.ai>
6
+ License-Expression: Apache-2.0
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ License-File: NOTICE
11
+ Requires-Dist: httpx>=0.27
12
+ Requires-Dist: pyyaml>=6
13
+ Requires-Dist: starlette>=0.37
14
+ Requires-Dist: uvicorn>=0.29
15
+ Provides-Extra: test
16
+ Requires-Dist: pytest>=8; extra == "test"
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=8; extra == "dev"
19
+ Requires-Dist: ruff>=0.6; extra == "dev"
20
+ Requires-Dist: pip-audit>=2.7; extra == "dev"
21
+ Dynamic: license-file
22
+
23
+ # EndoRouter
24
+
25
+ A model router that decides **where a prompt is allowed to go** before it decides which model is best.
26
+
27
+ Most routers send everything to the cloud and try to catch the sensitive requests on the way out. That fails open: anything the detectors do not recognise, such as a strategy memo, a patient note, or proprietary code, leaves the building. EndoRouter fails closed. By default, work stays on your local model unless its provenance says it is public; an optional balanced mode also lets a local classifier clear unlabelled work. Every send, to a cloud model or to that local classifier, is written to an audit log before a byte of it leaves.
28
+
29
+ ```
30
+ client ──▶ endorouter ──▶ scan ─▶ label ─▶ decide ─▶ audit ─▶ dispatch
31
+ │
32
+ private or unknown ──────────────┴──▶ local model only
33
+ public (by provenance) ─────────────▶ local or cloud, by preference
34
+ ```
35
+
36
+ It speaks the OpenAI chat completions API for text chat, so a client that lets you set a base URL can use it. In the default strict mode, sending anything to the cloud takes a public label, or a source matching `public_sources`, from a trusted client, per request, with the headers described below. A client that cannot send headers still works: in strict mode everything it sends stays local, and in balanced mode the local classifier decides.
37
+
38
+ ## Quickstart
39
+
40
+ You need Python 3.10 or newer on macOS or Linux, `lsof` (installed by default on macOS), and a local model server that is already running, such as Ollama.
41
+
42
+ ```
43
+ pip install endorouter
44
+ endorouter serve
45
+ ```
46
+
47
+ Then point your client at `http://127.0.0.1:8787/v1` and ask for the model `auto`:
48
+
49
+ ```
50
+ curl http://127.0.0.1:8787/v1/chat/completions -H 'content-type: application/json' \
51
+ -d '{"model": "auto", "messages": [{"role": "user", "content": "hello"}]}'
52
+ ```
53
+
54
+ There are no questions and no config file. On start the router does three things:
55
+
56
+ - **It finds your local model and checks that it really is local.** It looks at the ports Ollama, LM Studio, llama.cpp, vLLM and Jan use by default. A server is trusted only if the program behind the port is known to run models on this machine. It also skips any Ollama model that is hosted remotely. Anything it cannot verify, such as a gateway or proxy, is reported with the full command line of the program on that port, and not used. If you know that program runs models here, trust it yourself and name the model: `endorouter init --trust vllm=Qwen/Qwen3-8B`. Only a server that speaks the Ollama API can be trusted by name alone, because it reports which of its models are hosted. Verified servers are re-checked before every send, with no caching: the program on the port, and for Ollama whether the model is still local. A port served by a different program, or an Ollama model that is now hosted remotely, is skipped until the check passes again.
57
+ - **It adds cloud providers only if their key is already set.** Examples are `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `MISTRAL_API_KEY`, `GROQ_API_KEY` and `OPENROUTER_API_KEY`. A client asks for a cloud model as `openai/gpt-5`, and gets it only for work labelled public.
58
+ - **It protects standard secret files.** Examples are `.env`, `*.pem`, SSH keys, `.aws/credentials` and `.netrc`. It runs in strict mode, so anything unlabelled stays local.
59
+
60
+ On Linux, Ollama usually runs as a system service under its own user, and discovery cannot see which program owns a port another user runs. Trust it once by name, and the router writes that into its config: `endorouter init --trust ollama`, then `endorouter serve`. A target trusted this way is taken at your word: the router cannot check which program owns its port, though it still asks Ollama before every send whether the model runs on this machine.
61
+
62
+ To see or change what it found, write it to a file:
63
+
64
+ ```
65
+ endorouter init # writes endorouter.yaml; nothing is asked, everything is detected
66
+ endorouter doctor
67
+ ```
68
+
69
+ To see a decision without sending anything:
70
+
71
+ ```
72
+ endorouter explain --source clients/acme/brief.md < prompt.txt
73
+ ```
74
+
75
+ ## How a request is labelled
76
+
77
+ Every request gets one label: `public`, `unknown` or `private`. Labels only ever combine toward the most restrictive.
78
+
79
+ | Signal | Effect |
80
+ |---|---|
81
+ | No provenance | `unknown`, which stays local in strict mode |
82
+ | A source matching `public_sources`, sent by a trusted client | may become `public` |
83
+ | A source matching `private_sources` | `private` |
84
+ | Any path | normalised first, so `docs/public/../x` is matched as `x`; a path that climbs above its root, such as `../x`, is `unknown` |
85
+ | A structural detector finding anywhere in the request | `private` |
86
+ | The optional local classifier | can tighten; in balanced mode it may also clear `unknown`, and the audit log says so |
87
+
88
+ Clients pass provenance with headers. Send one `x-endorouter-source` header per source, since a path or URL can contain a comma:
89
+
90
+ ```
91
+ x-endorouter-source: docs/public/intro.md
92
+ x-endorouter-source: https://example.com/post
93
+ x-endorouter-label: public
94
+ ```
95
+
96
+ If a proxy joins repeated headers with commas, each comma-separated piece is still checked against `private_sources`, so a private piece keeps the request private.
97
+
98
+ Only clients listed in `trusted_clients` can declare anything public. Any client can declare `private`.
99
+
100
+ **Detectors check formats, not word lists.** One rule needs no vendor list at all. It flags any token shaped like a machine-generated secret: long, random, mixing character classes, and not made of word-like runs. It catches keys from vendors nobody has written a pattern for. On 600 synthetic keys from made-up vendors it found 95 to 97% of base62, base64 and base64url shapes, but only 53% of bare hex, which looks the same as a file hash. On the Python 3.14 standard library (38.8 MB of code and docs) it raised 0.62 false alarms per megabyte; expect more on lockfiles and minified bundles, which were not in that corpus. `python bench/shape_eval.py` reproduces these numbers from a fixed seed. Precise format rules sit alongside it for private keys, cloud and SaaS tokens, JWTs with a decodable header, credentials in URLs, card numbers with a valid issuer prefix, length and Luhn checksum, US social security numbers, email addresses and phone numbers. They scan every field that is forwarded upstream: all messages including history, tool calls, tool results, tool definitions, stop sequences and response schemas. That includes dict keys, numbers, and JSON carried inside strings. Before matching, text is NFKC-normalised, every invisible and default-ignorable character is removed, and common Cyrillic and Greek look-alike letters are mapped to Latin. Base64 runs are decoded one level and scanned too, with no cap on how many. Structure nested too deeply to inspect counts as a finding. Scanning time grows in proportion to the input on every adversarial input we know of: each repetition of one or two characters from a broad set, a run of every code point that normalises to an accent or other mark, and the inputs reviewers found, which tests hold; memory grows in proportion to the request too, including for deeply nested JSON (34 MiB for a 5 MB nested schema). That is a measured property, not a proof. On the standard-library corpus scanning took 0.28 seconds per megabyte on an M2 Max; the slowest inputs we know of take 2 to 4 seconds per megabyte (digits and dashes, or many card numbers), and while any of these scanned, other requests waited at most about 0.2 seconds; for a 4 MB request of a million tiny strings, read, checked and scanned through the HTTP path, about 0.13 seconds. Request bodies over 4 MB are refused: unread when the size is declared, and read no further than 4 MB when it is not.
101
+
102
+ Where a value is assigned to an upper-case name, such as `DB_PASSWORD=...` or `SECRET_KEY = '...'`, the whole value is scored, punctuation included. That covers generated passwords and Django-style keys, which punctuation would otherwise split into short pieces. From the same script: 90% of 200 Django-style keys, 82% of 200 twenty-character passwords, and 98% of 200 forty-character AWS-style secrets. Known misses of the shape rule: bare hex, which looks like a file hash; short keys; all-lowercase keys with long runs of letters, which read as words (most of the Django misses); and keys made of plain words, such as `BlueHorseBatteryStaple77`.
103
+
104
+ What detectors cannot see: secrets split across messages in ways they do not rejoin (they rejoin a key whose issuer prefix stands alone, and text typed out letter by letter, but not arbitrary pieces), encrypted or compressed data, other encodings, and anything that is sensitive because of what it means rather than how it looks. That is the reason unlabelled work stays local by default.
105
+
106
+ ## Guarantees
107
+
108
+ These are enforced in code and pinned by tests.
109
+
110
+ - A private or unknown request never selects a cloud target in strict mode. Asking for a cloud model by name is refused, not honoured.
111
+ - If the local model is down, a private or unknown request fails. It never falls back to the cloud. A public request may fall back to any permitted target.
112
+ - Fallback only moves between targets that were already permitted.
113
+ - Every send is preceded by a flushed audit record naming where it goes: a `classifier_dispatch` record before the local classifier reads the text, and an `attempt` record, after the `decision` record, before each target. If a record cannot be written before the first send, nothing is sent. Once anything has been sent, to the classifier or to a target, an audit failure is reported as 502 "sent, but not recorded", naming every recipient, and the response is discarded. Records are file-locked, so separate processes never interleave them.
114
+ - The decision record names the caller's address, whether it was trusted, and the label it declared, which the record never rewrites: `declared_applied` says whether policy used it (an untrusted caller's "public" is recorded but not applied), and a source that makes a request private appears as its own reason. Every process on this machine shares 127.0.0.1, so the address says which trusted client sent a public label only when you trust a single one. A classifier that fails is recorded as `classifier_failed` with the kind of failure.
115
+ - The audit log holds decisions and reasons, never prompt content.
116
+ - The HTTP client follows no redirects and ignores proxy environment variables. As a library, the router refuses an injected client that trusts the environment. It converts each request to plain JSON once, then validates, scans and sends exactly that, so a tuple or a numeric key cannot be scanned in one shape and sent in another.
117
+ - It listens on loopback only. On every route it answers only requests addressed to `localhost`, `127.0.0.1` or `[::1]` and carrying no browser `Origin` header, and chat requests must be sent as `application/json`. A web page therefore cannot reach it, by DNS rebinding or by a cross-site form or fetch.
118
+ - Unknown request fields and non-text content are rejected rather than passed through unexamined. That covers nested tool calls, tools and response formats.
119
+ - A typo in the config is an error, never a silent default.
120
+
121
+ ## The trust boundary
122
+
123
+ A router can only enforce what it is told, so this section matters more than the rest.
124
+
125
+ - **Localhost is not proof of local inference.** Some local servers can proxy requests to a hosted model. Discovery trusts only known local-inference programs, and it checks Ollama models for remote hosting. A target you declare `local` yourself, in a config file or with `--trust`, is taken at your word. `endorouter doctor` reminds you of this for every local target.
126
+ - **Provenance is only as good as the client that sends it.** Configure `trusted_clients` narrowly. The router ignores `X-Forwarded-For`, so no caller can borrow a trusted address. Do not put a shared reverse proxy in front of it: the proxy becomes the peer, and if it is trusted, everyone behind it can label work public.
127
+ - **A static public label moves the decision to the model picker.** Many clients can only send fixed headers. If you set `x-endorouter-label: public` on every request, every request counts as public, and only the detectors stand between a pasted secret and the cloud model you chose. Label per request where you can, or route by source paths with `public_sources`.
128
+ - **Detectors catch formats, not meaning.** That is why the default is local. In balanced mode, unlabelled work can reach the cloud if the local classifier calls it public, and that is a judgement call you opt into.
129
+
130
+ ## leakbench
131
+
132
+ leakbench measures whether private data reaches a cloud through any OpenAI-compatible gateway, not just this one. It starts two recording fake servers, one standing in for the cloud and one for the local model. It sends each case exactly as written, nothing added, one at a time, and keeps listening a second after the last. Cases are told apart by what they say: message text and names, refusals, tool calls (names and arguments), tool definitions and schemas (property names, numbers and allowed values included), but never the API's own syntax (field names, roles, types, ids). Text is compared the way the detectors read it, so removing an invisible character or changing spacing on the way does not hide anything. Every case must say something of 8 or more characters that no other case says, be a request a router would accept, and have a unique id and a well-formed truth, label and sources; otherwise the suite is refused. The calibration request, sent first and observed on its own, shows what the gateway adds to every request itself (a system prompt, default stop sequences); that text is never evidence. A private case leaked if any of its strings, or any secret-like word in them (8+ characters with a digit, a symbol or mixed case, such as a key cut out of its sentence or an assignment, or a password with punctuation), that no public case says arrived at the cloud sink inside one received string, whenever it arrived. A case counts as having run only if text unique to it reached a sink. What it cannot see: strings and numbers under 8 characters (each report lists them per case as unmeasured), ordinary words taken out of their sentence, and text a gateway rewrites beyond recognition. leakbench measures gateways that are not built to fool it. One that adds different text for different models, merges messages, or moves one case's text into another's request in disguise can still mislead its attribution: a validity check catches the plain cases (nothing arrived, unattributable traffic, a failed calibration), not every evasion. The result is measured at the sink, never taken from the gateway's own report.
133
+
134
+ ```
135
+ endorouter leakbench --base-url http://127.0.0.1:8787/v1
136
+ ```
137
+
138
+ Point the gateway's cloud destination at port 8799 and its local destination at 8800. A gateway that answers nothing must never score as one that leaks nothing, so four checks make a run invalid:
139
+
140
+ - A benign calibration request does not reach a sink. This means the gateway is down, misconfigured or missing credentials.
141
+ - A case reaches no sink and was not refused with HTTP 400, 403 or 451.
142
+ - A successful answer did not come from one of the recording sinks. This means the gateway has a destination leakbench cannot see.
143
+ - A request reached a sink carrying no case's content, so it cannot be attributed.
144
+
145
+ Each refusal's response text is kept in the report, so a reader can check it was a policy decision. A refusal is still the gateway's own claim, because leakbench cannot see egress outside its sinks. Reports therefore count refusals separately and mark them unverified.
146
+
147
+ Results are in [bench/RESULTS.md](bench/RESULTS.md), with the commands to reproduce them. Read the strict-mode zero for what it is: unlabelled work never goes to the cloud, so every unlabelled private case stays local whether or not a detector fires. That zero holds by construction. The detectors are tested where they matter, in the boundary suite, which sends secrets under a public label.
148
+
149
+ ## Status
150
+
151
+ Version 0.1, pre-release. macOS and Linux. Text chat completions, with and without streaming. Not yet supported: images, audio, embeddings and the Responses API. Requests that use them are refused rather than passed through. Known gap: base64 wrapped across lines (as in PEM or MIME bodies) is not reassembled before decoding.
152
+
153
+ Built by Jacob Ashley with Claude. Licence: [Apache-2.0](LICENSE), copyright 2026 J. I. Ashley Consulting LLC. Security reports: see [SECURITY.md](SECURITY.md). Contact: hello@smtry.ai.
@@ -0,0 +1,131 @@
1
+ # EndoRouter
2
+
3
+ A model router that decides **where a prompt is allowed to go** before it decides which model is best.
4
+
5
+ Most routers send everything to the cloud and try to catch the sensitive requests on the way out. That fails open: anything the detectors do not recognise, such as a strategy memo, a patient note, or proprietary code, leaves the building. EndoRouter fails closed. By default, work stays on your local model unless its provenance says it is public; an optional balanced mode also lets a local classifier clear unlabelled work. Every send, to a cloud model or to that local classifier, is written to an audit log before a byte of it leaves.
6
+
7
+ ```
8
+ client ──▶ endorouter ──▶ scan ─▶ label ─▶ decide ─▶ audit ─▶ dispatch
9
+ │
10
+ private or unknown ──────────────┴──▶ local model only
11
+ public (by provenance) ─────────────▶ local or cloud, by preference
12
+ ```
13
+
14
+ It speaks the OpenAI chat completions API for text chat, so a client that lets you set a base URL can use it. In the default strict mode, sending anything to the cloud takes a public label, or a source matching `public_sources`, from a trusted client, per request, with the headers described below. A client that cannot send headers still works: in strict mode everything it sends stays local, and in balanced mode the local classifier decides.
15
+
16
+ ## Quickstart
17
+
18
+ You need Python 3.10 or newer on macOS or Linux, `lsof` (installed by default on macOS), and a local model server that is already running, such as Ollama.
19
+
20
+ ```
21
+ pip install endorouter
22
+ endorouter serve
23
+ ```
24
+
25
+ Then point your client at `http://127.0.0.1:8787/v1` and ask for the model `auto`:
26
+
27
+ ```
28
+ curl http://127.0.0.1:8787/v1/chat/completions -H 'content-type: application/json' \
29
+ -d '{"model": "auto", "messages": [{"role": "user", "content": "hello"}]}'
30
+ ```
31
+
32
+ There are no questions and no config file. On start the router does three things:
33
+
34
+ - **It finds your local model and checks that it really is local.** It looks at the ports Ollama, LM Studio, llama.cpp, vLLM and Jan use by default. A server is trusted only if the program behind the port is known to run models on this machine. It also skips any Ollama model that is hosted remotely. Anything it cannot verify, such as a gateway or proxy, is reported with the full command line of the program on that port, and not used. If you know that program runs models here, trust it yourself and name the model: `endorouter init --trust vllm=Qwen/Qwen3-8B`. Only a server that speaks the Ollama API can be trusted by name alone, because it reports which of its models are hosted. Verified servers are re-checked before every send, with no caching: the program on the port, and for Ollama whether the model is still local. A port served by a different program, or an Ollama model that is now hosted remotely, is skipped until the check passes again.
35
+ - **It adds cloud providers only if their key is already set.** Examples are `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `MISTRAL_API_KEY`, `GROQ_API_KEY` and `OPENROUTER_API_KEY`. A client asks for a cloud model as `openai/gpt-5`, and gets it only for work labelled public.
36
+ - **It protects standard secret files.** Examples are `.env`, `*.pem`, SSH keys, `.aws/credentials` and `.netrc`. It runs in strict mode, so anything unlabelled stays local.
37
+
38
+ On Linux, Ollama usually runs as a system service under its own user, and discovery cannot see which program owns a port another user runs. Trust it once by name, and the router writes that into its config: `endorouter init --trust ollama`, then `endorouter serve`. A target trusted this way is taken at your word: the router cannot check which program owns its port, though it still asks Ollama before every send whether the model runs on this machine.
39
+
40
+ To see or change what it found, write it to a file:
41
+
42
+ ```
43
+ endorouter init # writes endorouter.yaml; nothing is asked, everything is detected
44
+ endorouter doctor
45
+ ```
46
+
47
+ To see a decision without sending anything:
48
+
49
+ ```
50
+ endorouter explain --source clients/acme/brief.md < prompt.txt
51
+ ```
52
+
53
+ ## How a request is labelled
54
+
55
+ Every request gets one label: `public`, `unknown` or `private`. Labels only ever combine toward the most restrictive.
56
+
57
+ | Signal | Effect |
58
+ |---|---|
59
+ | No provenance | `unknown`, which stays local in strict mode |
60
+ | A source matching `public_sources`, sent by a trusted client | may become `public` |
61
+ | A source matching `private_sources` | `private` |
62
+ | Any path | normalised first, so `docs/public/../x` is matched as `x`; a path that climbs above its root, such as `../x`, is `unknown` |
63
+ | A structural detector finding anywhere in the request | `private` |
64
+ | The optional local classifier | can tighten; in balanced mode it may also clear `unknown`, and the audit log says so |
65
+
66
+ Clients pass provenance with headers. Send one `x-endorouter-source` header per source, since a path or URL can contain a comma:
67
+
68
+ ```
69
+ x-endorouter-source: docs/public/intro.md
70
+ x-endorouter-source: https://example.com/post
71
+ x-endorouter-label: public
72
+ ```
73
+
74
+ If a proxy joins repeated headers with commas, each comma-separated piece is still checked against `private_sources`, so a private piece keeps the request private.
75
+
76
+ Only clients listed in `trusted_clients` can declare anything public. Any client can declare `private`.
77
+
78
+ **Detectors check formats, not word lists.** One rule needs no vendor list at all. It flags any token shaped like a machine-generated secret: long, random, mixing character classes, and not made of word-like runs. It catches keys from vendors nobody has written a pattern for. On 600 synthetic keys from made-up vendors it found 95 to 97% of base62, base64 and base64url shapes, but only 53% of bare hex, which looks the same as a file hash. On the Python 3.14 standard library (38.8 MB of code and docs) it raised 0.62 false alarms per megabyte; expect more on lockfiles and minified bundles, which were not in that corpus. `python bench/shape_eval.py` reproduces these numbers from a fixed seed. Precise format rules sit alongside it for private keys, cloud and SaaS tokens, JWTs with a decodable header, credentials in URLs, card numbers with a valid issuer prefix, length and Luhn checksum, US social security numbers, email addresses and phone numbers. They scan every field that is forwarded upstream: all messages including history, tool calls, tool results, tool definitions, stop sequences and response schemas. That includes dict keys, numbers, and JSON carried inside strings. Before matching, text is NFKC-normalised, every invisible and default-ignorable character is removed, and common Cyrillic and Greek look-alike letters are mapped to Latin. Base64 runs are decoded one level and scanned too, with no cap on how many. Structure nested too deeply to inspect counts as a finding. Scanning time grows in proportion to the input on every adversarial input we know of: each repetition of one or two characters from a broad set, a run of every code point that normalises to an accent or other mark, and the inputs reviewers found, which tests hold; memory grows in proportion to the request too, including for deeply nested JSON (34 MiB for a 5 MB nested schema). That is a measured property, not a proof. On the standard-library corpus scanning took 0.28 seconds per megabyte on an M2 Max; the slowest inputs we know of take 2 to 4 seconds per megabyte (digits and dashes, or many card numbers), and while any of these scanned, other requests waited at most about 0.2 seconds; for a 4 MB request of a million tiny strings, read, checked and scanned through the HTTP path, about 0.13 seconds. Request bodies over 4 MB are refused: unread when the size is declared, and read no further than 4 MB when it is not.
79
+
80
+ Where a value is assigned to an upper-case name, such as `DB_PASSWORD=...` or `SECRET_KEY = '...'`, the whole value is scored, punctuation included. That covers generated passwords and Django-style keys, which punctuation would otherwise split into short pieces. From the same script: 90% of 200 Django-style keys, 82% of 200 twenty-character passwords, and 98% of 200 forty-character AWS-style secrets. Known misses of the shape rule: bare hex, which looks like a file hash; short keys; all-lowercase keys with long runs of letters, which read as words (most of the Django misses); and keys made of plain words, such as `BlueHorseBatteryStaple77`.
81
+
82
+ What detectors cannot see: secrets split across messages in ways they do not rejoin (they rejoin a key whose issuer prefix stands alone, and text typed out letter by letter, but not arbitrary pieces), encrypted or compressed data, other encodings, and anything that is sensitive because of what it means rather than how it looks. That is the reason unlabelled work stays local by default.
83
+
84
+ ## Guarantees
85
+
86
+ These are enforced in code and pinned by tests.
87
+
88
+ - A private or unknown request never selects a cloud target in strict mode. Asking for a cloud model by name is refused, not honoured.
89
+ - If the local model is down, a private or unknown request fails. It never falls back to the cloud. A public request may fall back to any permitted target.
90
+ - Fallback only moves between targets that were already permitted.
91
+ - Every send is preceded by a flushed audit record naming where it goes: a `classifier_dispatch` record before the local classifier reads the text, and an `attempt` record, after the `decision` record, before each target. If a record cannot be written before the first send, nothing is sent. Once anything has been sent, to the classifier or to a target, an audit failure is reported as 502 "sent, but not recorded", naming every recipient, and the response is discarded. Records are file-locked, so separate processes never interleave them.
92
+ - The decision record names the caller's address, whether it was trusted, and the label it declared, which the record never rewrites: `declared_applied` says whether policy used it (an untrusted caller's "public" is recorded but not applied), and a source that makes a request private appears as its own reason. Every process on this machine shares 127.0.0.1, so the address says which trusted client sent a public label only when you trust a single one. A classifier that fails is recorded as `classifier_failed` with the kind of failure.
93
+ - The audit log holds decisions and reasons, never prompt content.
94
+ - The HTTP client follows no redirects and ignores proxy environment variables. As a library, the router refuses an injected client that trusts the environment. It converts each request to plain JSON once, then validates, scans and sends exactly that, so a tuple or a numeric key cannot be scanned in one shape and sent in another.
95
+ - It listens on loopback only. On every route it answers only requests addressed to `localhost`, `127.0.0.1` or `[::1]` and carrying no browser `Origin` header, and chat requests must be sent as `application/json`. A web page therefore cannot reach it, by DNS rebinding or by a cross-site form or fetch.
96
+ - Unknown request fields and non-text content are rejected rather than passed through unexamined. That covers nested tool calls, tools and response formats.
97
+ - A typo in the config is an error, never a silent default.
98
+
99
+ ## The trust boundary
100
+
101
+ A router can only enforce what it is told, so this section matters more than the rest.
102
+
103
+ - **Localhost is not proof of local inference.** Some local servers can proxy requests to a hosted model. Discovery trusts only known local-inference programs, and it checks Ollama models for remote hosting. A target you declare `local` yourself, in a config file or with `--trust`, is taken at your word. `endorouter doctor` reminds you of this for every local target.
104
+ - **Provenance is only as good as the client that sends it.** Configure `trusted_clients` narrowly. The router ignores `X-Forwarded-For`, so no caller can borrow a trusted address. Do not put a shared reverse proxy in front of it: the proxy becomes the peer, and if it is trusted, everyone behind it can label work public.
105
+ - **A static public label moves the decision to the model picker.** Many clients can only send fixed headers. If you set `x-endorouter-label: public` on every request, every request counts as public, and only the detectors stand between a pasted secret and the cloud model you chose. Label per request where you can, or route by source paths with `public_sources`.
106
+ - **Detectors catch formats, not meaning.** That is why the default is local. In balanced mode, unlabelled work can reach the cloud if the local classifier calls it public, and that is a judgement call you opt into.
107
+
108
+ ## leakbench
109
+
110
+ leakbench measures whether private data reaches a cloud through any OpenAI-compatible gateway, not just this one. It starts two recording fake servers, one standing in for the cloud and one for the local model. It sends each case exactly as written, nothing added, one at a time, and keeps listening a second after the last. Cases are told apart by what they say: message text and names, refusals, tool calls (names and arguments), tool definitions and schemas (property names, numbers and allowed values included), but never the API's own syntax (field names, roles, types, ids). Text is compared the way the detectors read it, so removing an invisible character or changing spacing on the way does not hide anything. Every case must say something of 8 or more characters that no other case says, be a request a router would accept, and have a unique id and a well-formed truth, label and sources; otherwise the suite is refused. The calibration request, sent first and observed on its own, shows what the gateway adds to every request itself (a system prompt, default stop sequences); that text is never evidence. A private case leaked if any of its strings, or any secret-like word in them (8+ characters with a digit, a symbol or mixed case, such as a key cut out of its sentence or an assignment, or a password with punctuation), that no public case says arrived at the cloud sink inside one received string, whenever it arrived. A case counts as having run only if text unique to it reached a sink. What it cannot see: strings and numbers under 8 characters (each report lists them per case as unmeasured), ordinary words taken out of their sentence, and text a gateway rewrites beyond recognition. leakbench measures gateways that are not built to fool it. One that adds different text for different models, merges messages, or moves one case's text into another's request in disguise can still mislead its attribution: a validity check catches the plain cases (nothing arrived, unattributable traffic, a failed calibration), not every evasion. The result is measured at the sink, never taken from the gateway's own report.
111
+
112
+ ```
113
+ endorouter leakbench --base-url http://127.0.0.1:8787/v1
114
+ ```
115
+
116
+ Point the gateway's cloud destination at port 8799 and its local destination at 8800. A gateway that answers nothing must never score as one that leaks nothing, so four checks make a run invalid:
117
+
118
+ - A benign calibration request does not reach a sink. This means the gateway is down, misconfigured or missing credentials.
119
+ - A case reaches no sink and was not refused with HTTP 400, 403 or 451.
120
+ - A successful answer did not come from one of the recording sinks. This means the gateway has a destination leakbench cannot see.
121
+ - A request reached a sink carrying no case's content, so it cannot be attributed.
122
+
123
+ Each refusal's response text is kept in the report, so a reader can check it was a policy decision. A refusal is still the gateway's own claim, because leakbench cannot see egress outside its sinks. Reports therefore count refusals separately and mark them unverified.
124
+
125
+ Results are in [bench/RESULTS.md](bench/RESULTS.md), with the commands to reproduce them. Read the strict-mode zero for what it is: unlabelled work never goes to the cloud, so every unlabelled private case stays local whether or not a detector fires. That zero holds by construction. The detectors are tested where they matter, in the boundary suite, which sends secrets under a public label.
126
+
127
+ ## Status
128
+
129
+ Version 0.1, pre-release. macOS and Linux. Text chat completions, with and without streaming. Not yet supported: images, audio, embeddings and the Responses API. Requests that use them are refused rather than passed through. Known gap: base64 wrapped across lines (as in PEM or MIME bodies) is not reassembled before decoding.
130
+
131
+ Built by Jacob Ashley with Claude. Licence: [Apache-2.0](LICENSE), copyright 2026 J. I. Ashley Consulting LLC. Security reports: see [SECURITY.md](SECURITY.md). Contact: hello@smtry.ai.
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "endorouter"
7
+ version = "0.1.0"
8
+ description = "A model router that decides where a prompt is allowed to go before it decides which model is best. Default local; cloud only when provenance says public. Every decision audited."
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ license-files = ["LICENSE", "NOTICE"]
12
+ authors = [{ name = "J. I. Ashley Consulting LLC", email = "hello@smtry.ai" }]
13
+ requires-python = ">=3.10"
14
+ dependencies = ["httpx>=0.27", "pyyaml>=6", "starlette>=0.37", "uvicorn>=0.29"]
15
+
16
+ [project.optional-dependencies]
17
+ test = ["pytest>=8"]
18
+ dev = ["pytest>=8", "ruff>=0.6", "pip-audit>=2.7"]
19
+
20
+ [project.scripts]
21
+ endorouter = "endorouter.cli:main"
22
+
23
+ [tool.setuptools.packages.find]
24
+ where = ["src"]
25
+
26
+ [tool.setuptools.package-data]
27
+ endorouter = ["leakbench/*.jsonl", "example.yaml"]
28
+
29
+ [tool.pytest.ini_options]
30
+ testpaths = ["tests"]
31
+
32
+ [tool.ruff]
33
+ target-version = "py310"
34
+ line-length = 120
35
+
36
+ [tool.ruff.lint]
37
+ # pyflakes, pycodestyle, bugbear, blind except, import order
38
+ select = ["F", "E", "W", "B", "BLE", "I"]
39
+ ignore = ["E501"] # long lines are a style choice here, not a defect; the code wraps at about 120 by hand
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,10 @@
1
+ """endorouter: decide where a prompt may go before deciding which model is best."""
2
+
3
+ from .config import Config, ConfigError, Target, load_config
4
+ from .labels import Label
5
+ from .policy import Decision, decide
6
+
7
+ __version__ = "0.1.0"
8
+ POLICY_VERSION = "1"
9
+
10
+ __all__ = ["Config", "ConfigError", "Target", "load_config", "Label", "Decision", "decide", "__version__", "POLICY_VERSION"]
@@ -0,0 +1,41 @@
1
+ """Append-only JSONL audit log. A record is written and flushed to disk BEFORE anything is sent upstream; if it cannot
2
+ be written, the request is refused. Records never contain prompt or completion content."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import fcntl
7
+ import json
8
+ import os
9
+ import threading
10
+ import time
11
+
12
+
13
+ class AuditError(RuntimeError):
14
+ pass
15
+
16
+
17
+ class AuditLog:
18
+ def __init__(self, path: str):
19
+ self.path = path
20
+ self._lock = threading.Lock()
21
+
22
+ def write(self, record: dict) -> None:
23
+ line = json.dumps({"ts": round(time.time(), 3), **record}, separators=(",", ":"), sort_keys=True) + "\n"
24
+ try:
25
+ with self._lock:
26
+ fd = os.open(self.path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
27
+ try:
28
+ # an exclusive lock on the file, held through write and fsync, so separate AuditLog instances and
29
+ # separate processes can never interleave the bytes of two records
30
+ fcntl.flock(fd, fcntl.LOCK_EX)
31
+ data = memoryview(line.encode("utf-8"))
32
+ while data: # a short write is not a record: keep writing until every byte is accepted
33
+ n = os.write(fd, data)
34
+ if n <= 0:
35
+ raise OSError("audit write made no progress")
36
+ data = data[n:]
37
+ os.fsync(fd)
38
+ finally:
39
+ os.close(fd)
40
+ except OSError as e:
41
+ raise AuditError(f"audit log unavailable ({e}); refusing to dispatch") from e