preflight-gate 0.7.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 (55) hide show
  1. preflight_gate-0.7.0/.gitignore +15 -0
  2. preflight_gate-0.7.0/CHANGELOG.md +26 -0
  3. preflight_gate-0.7.0/CONTRIBUTING.md +27 -0
  4. preflight_gate-0.7.0/LICENSE +21 -0
  5. preflight_gate-0.7.0/PKG-INFO +384 -0
  6. preflight_gate-0.7.0/README.md +331 -0
  7. preflight_gate-0.7.0/SECURITY.md +27 -0
  8. preflight_gate-0.7.0/docs/MANUAL.md +1774 -0
  9. preflight_gate-0.7.0/examples/host.py +71 -0
  10. preflight_gate-0.7.0/examples/plugins/collider/__init__.py +8 -0
  11. preflight_gate-0.7.0/examples/plugins/collider/manifest.json +22 -0
  12. preflight_gate-0.7.0/examples/plugins/collider/plugin.py +32 -0
  13. preflight_gate-0.7.0/examples/plugins/greeter/__init__.py +9 -0
  14. preflight_gate-0.7.0/examples/plugins/greeter/manifest.json +22 -0
  15. preflight_gate-0.7.0/examples/plugins/greeter/plugin.py +36 -0
  16. preflight_gate-0.7.0/examples/plugins/impostor/__init__.py +10 -0
  17. preflight_gate-0.7.0/examples/plugins/impostor/manifest.json +28 -0
  18. preflight_gate-0.7.0/examples/plugins/impostor/plugin.py +57 -0
  19. preflight_gate-0.7.0/examples/plugins/janitor/__init__.py +12 -0
  20. preflight_gate-0.7.0/examples/plugins/janitor/manifest.json +22 -0
  21. preflight_gate-0.7.0/examples/plugins/janitor/plugin.py +43 -0
  22. preflight_gate-0.7.0/examples/plugins/trespasser/README.md +17 -0
  23. preflight_gate-0.7.0/examples/plugins/trespasser/manifest.json +15 -0
  24. preflight_gate-0.7.0/pyproject.toml +102 -0
  25. preflight_gate-0.7.0/scripts/audit_dist.py +89 -0
  26. preflight_gate-0.7.0/scripts/coldstart/README.md +71 -0
  27. preflight_gate-0.7.0/scripts/coldstart/briefing.md +44 -0
  28. preflight_gate-0.7.0/scripts/coldstart/reports/2026-08-06-first-pass.md +104 -0
  29. preflight_gate-0.7.0/scripts/coldstart/reports/2026-08-23-triage.md +95 -0
  30. preflight_gate-0.7.0/scripts/coldstart/tasks.md +86 -0
  31. preflight_gate-0.7.0/scripts/uat.sh +413 -0
  32. preflight_gate-0.7.0/src/preflight/__init__.py +112 -0
  33. preflight_gate-0.7.0/src/preflight/__main__.py +5 -0
  34. preflight_gate-0.7.0/src/preflight/cli.py +1177 -0
  35. preflight_gate-0.7.0/src/preflight/inspect.py +824 -0
  36. preflight_gate-0.7.0/src/preflight/load.py +292 -0
  37. preflight_gate-0.7.0/src/preflight/manifest.py +431 -0
  38. preflight_gate-0.7.0/src/preflight/py.typed +0 -0
  39. preflight_gate-0.7.0/src/preflight/registry.py +674 -0
  40. preflight_gate-0.7.0/src/preflight/settings.py +531 -0
  41. preflight_gate-0.7.0/tests/conftest.py +47 -0
  42. preflight_gate-0.7.0/tests/test_bare_entrypoint.py +230 -0
  43. preflight_gate-0.7.0/tests/test_check_matches_the_gate.py +434 -0
  44. preflight_gate-0.7.0/tests/test_cli.py +745 -0
  45. preflight_gate-0.7.0/tests/test_docs.py +302 -0
  46. preflight_gate-0.7.0/tests/test_entrypoint_confinement.py +366 -0
  47. preflight_gate-0.7.0/tests/test_examples.py +130 -0
  48. preflight_gate-0.7.0/tests/test_inspect.py +392 -0
  49. preflight_gate-0.7.0/tests/test_load.py +425 -0
  50. preflight_gate-0.7.0/tests/test_load_hardening.py +38 -0
  51. preflight_gate-0.7.0/tests/test_negative_control.py +122 -0
  52. preflight_gate-0.7.0/tests/test_packaging.py +122 -0
  53. preflight_gate-0.7.0/tests/test_registry.py +448 -0
  54. preflight_gate-0.7.0/tests/test_settings.py +628 -0
  55. preflight_gate-0.7.0/tests/test_transcripts.py +320 -0
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .pytest-*/
5
+ .coverage
6
+ htmlcov/
7
+ .mypy_cache/
8
+ .ruff_cache/
9
+ .pip-audit-cache/
10
+ .omc/
11
+ .venv/
12
+ venv/
13
+ build/
14
+ dist/
15
+ *.egg-info/
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ All notable changes follow Keep a Changelog. Preflight uses semantic versioning
4
+ while its public API remains pre-1.0.
5
+
6
+ ## [Unreleased]
7
+
8
+ ## [0.7.0] - 2026-09-01
9
+
10
+ ### Added
11
+
12
+ - Distribution-content auditing and trusted-publishing workflows.
13
+ - Directory-wide package, plugin, and tool collision detection.
14
+ - Static entrypoint syntax diagnostics and broader Python binding analysis.
15
+ - Bare-module adaptation, settings profiles, stronger check/gate parity,
16
+ transcript verification, and a wheel-installed end-to-end demonstration.
17
+
18
+ ### Security
19
+
20
+ - Manifest limits are enforced before every parse, including load ordering and
21
+ terminal inspection.
22
+ - Sandbox tutorials require an inventoried ownership marker before reset.
23
+ - Symlinked entrypoint files cannot escape the trusted plugin root.
24
+
25
+ [Unreleased]: https://github.com/croresnos/preflight/compare/v0.7.0...HEAD
26
+ [0.7.0]: https://github.com/croresnos/preflight/releases/tag/v0.7.0
@@ -0,0 +1,27 @@
1
+ # Contributing
2
+
3
+ Preflight is a fail-closed security project. Changes should preserve the
4
+ distinction between facts known before import and failures discovered after an
5
+ import attempt.
6
+
7
+ ## Development
8
+
9
+ 1. Create a Python 3.11 or newer virtual environment.
10
+ 2. Install the development tools with: python -m pip install -e ".[dev]"
11
+ 3. Run:
12
+
13
+ python -m ruff check .
14
+ python -m ruff format --check .
15
+ python -m mypy
16
+ python -m bandit -q -r src
17
+ python -m pytest --cov
18
+
19
+ 4. Build both archives and audit them:
20
+
21
+ python -m build
22
+ python scripts/audit_dist.py dist
23
+ python -m twine check dist/*
24
+
25
+ Every security fix needs a negative test that fails when the enforcement branch
26
+ is weakened. Never include credentials, local agent state, or generated caches
27
+ in a commit or distribution.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Neil Mahure
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,384 @@
1
+ Metadata-Version: 2.5
2
+ Name: preflight-gate
3
+ Version: 0.7.0
4
+ Summary: Decide whether a plugin may load from its manifest alone, before any of its code runs.
5
+ Project-URL: Homepage, https://github.com/croresnos/preflight
6
+ Project-URL: Repository, https://github.com/croresnos/preflight
7
+ Project-URL: Issues, https://github.com/croresnos/preflight/issues
8
+ Author: Neil Mahure
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Neil Mahure
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: agent-tools,fail-closed,loader,manifest,plugins
32
+ Classifier: Development Status :: 4 - Beta
33
+ Classifier: Intended Audience :: Developers
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Programming Language :: Python :: 3.11
36
+ Classifier: Programming Language :: Python :: 3.12
37
+ Classifier: Programming Language :: Python :: 3.13
38
+ Classifier: Programming Language :: Python :: 3.14
39
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
40
+ Classifier: Typing :: Typed
41
+ Requires-Python: >=3.11
42
+ Requires-Dist: pydantic<3,>=2.0
43
+ Provides-Extra: dev
44
+ Requires-Dist: bandit<2,>=1.7; extra == 'dev'
45
+ Requires-Dist: build<2,>=1.2; extra == 'dev'
46
+ Requires-Dist: mypy<2,>=1.11; extra == 'dev'
47
+ Requires-Dist: pip-audit<3,>=2.7; extra == 'dev'
48
+ Requires-Dist: pytest-cov<8,>=5; extra == 'dev'
49
+ Requires-Dist: pytest<10,>=9.0.3; extra == 'dev'
50
+ Requires-Dist: ruff<1,>=0.12; extra == 'dev'
51
+ Requires-Dist: twine<8,>=7; extra == 'dev'
52
+ Description-Content-Type: text/markdown
53
+
54
+ # preflight
55
+
56
+ **Decide whether a plugin is allowed to load by reading its manifest file — before a single line of the plugin's code runs.**
57
+
58
+ ### Is this for you?
59
+
60
+ **Does your Python program load plugins from a folder?** If not, preflight has no
61
+ job in it, and the rest of this page will not change that.
62
+
63
+ preflight is a library. There is no app, no daemon, and no config file. You add one
64
+ function call to your program's startup, and from then on it decides which plugins
65
+ may be imported. That is the whole product.
66
+
67
+ It needs exactly three things:
68
+
69
+ 1. Your program has a `plugins/` folder.
70
+ 2. Each plugin subfolder has a `manifest.json` — written by its author, because your
71
+ application requires one, or by you with `preflight create`.
72
+ 3. Your startup code calls `load_plugins`.
73
+
74
+ **What it is not:** it does not gate pip packages, npm packages, MCP servers, or an
75
+ agent's built-in tools. It does not read plugin code, so it cannot detect malware.
76
+ It is a permission system for a plugin folder you own — the same shape as a browser
77
+ extension manifest.
78
+
79
+ ---
80
+
81
+ ## Why the manifest has to be a file
82
+
83
+ Importing a Python module runs it. So a loader that imports a plugin in order to
84
+ find out what it is has already let it do whatever it was going to do:
85
+
86
+ ```python
87
+ module = importlib.import_module(plugin_name) # <-- the plugin's code has now run
88
+ if module.MANIFEST["version"] not in SUPPORTED: # <-- too late
89
+ raise RuntimeError("unsupported plugin")
90
+ ```
91
+
92
+ There is no "import but don't execute" in Python. If the only description of a
93
+ plugin lives *inside* the plugin, you have to run the plugin to read it. So
94
+ preflight requires the description to live outside it, in inert JSON, and makes
95
+ every decision from that file before anything is imported.
96
+
97
+ ## Watch it refuse things
98
+
99
+ ```
100
+ pip install preflight-gate
101
+ preflight demo
102
+ ```
103
+
104
+ <!-- transcript: preflight demo | setup=repo -->
105
+ ```
106
+ [greeter] top-level plugin code is executing
107
+ [impostor] top-level plugin code is executing
108
+ [janitor] top-level plugin code is executing
109
+
110
+ preflight | plugins\ | 5 packages found
111
+
112
+ LOADED greeter Greeter 1.0.0 - 1 tool
113
+ REFUSED trespasser never imported
114
+ entrypoint module 'json' resolves to '<your python's stdlib>/json/__init__.py',
115
+ which is outside the trusted plugin root '<root>'
116
+ REFUSED collider never imported
117
+ tool name collision: 'greeter.hello' is already owned by 'greeter'
118
+ REFUSED impostor imported, then rejected
119
+ runtime manifest for 'example.impostor' does not match its
120
+ validated package manifest
121
+ tools -- undeclared in the manifest: impostor.purge_all_records
122
+ LOADED janitor Janitor 1.0.0 - 1 tool
123
+
124
+ 2 loaded, 3 refused -- 2 of the 3 stopped before any of their code ran
125
+
126
+ The 3 lines above reading `top-level plugin code is executing` are
127
+ tripwires: the first statement in a plugin package. 2 of the 3 refused
128
+ plugins never printed one, because they never got an import.
129
+
130
+ Try `preflight demo --refuse destructive` to watch a fourth
131
+ plugin refused for a tool it declared honestly -- and the one
132
+ that lied slip past the flag, because it declared nothing.
133
+ ```
134
+
135
+ Each example plugin prints a tripwire as the first statement of its `__init__.py`.
136
+ Three tripwires fired; five plugins were considered. **The two refusals with no
137
+ tripwire are the point of the project** — those plugins were turned away while
138
+ still inert text on disk.
139
+
140
+ `never imported` and `imported, then rejected` are both normal output, because the
141
+ difference between them is the honest measure of what preflight did for you.
142
+ `Outcome.code_ran` records it from the run itself rather than guessing from which
143
+ error came back.
144
+
145
+ ## The gate
146
+
147
+ ```
148
+ myapp/
149
+ ├── host.py
150
+ └── plugins/ <- the trusted root
151
+ └── greeter/
152
+ ├── __init__.py <- required; a namespace package has no file to check
153
+ ├── plugin.py
154
+ └── manifest.json
155
+ ```
156
+
157
+ `plugins/greeter/manifest.json`:
158
+
159
+ ```json
160
+ {
161
+ "package_id": "example.greeter",
162
+ "core_api_version": "1.0",
163
+ "visibility": "public",
164
+ "release_ring": "stable",
165
+ "entrypoint": "greeter.plugin:create_plugin",
166
+ "plugin": {
167
+ "plugin_id": "greeter",
168
+ "name": "Greeter",
169
+ "module_version": "1.0.0",
170
+ "tools": [{"name": "greeter.hello", "risk": "read"}]
171
+ }
172
+ }
173
+ ```
174
+
175
+ `host.py`:
176
+
177
+ ```python
178
+ import sys
179
+ from pathlib import Path
180
+
181
+ from preflight import load_plugins
182
+
183
+ PLUGINS = Path(__file__).resolve().parent / "plugins"
184
+
185
+ # preflight never modifies sys.path. Making the plugin directory importable is
186
+ # the host's job -- a library that mutates global import state as a side effect
187
+ # of a security check is worse than one that documents the requirement.
188
+ sys.path.insert(0, str(PLUGINS))
189
+
190
+ result = load_plugins(PLUGINS, allow=["example.greeter"])
191
+
192
+ print(result)
193
+ print(result.plugins["greeter"].hello("world")) # Hello, world.
194
+ ```
195
+
196
+ Three things that are load-bearing and easy to miss:
197
+
198
+ - **`allow` is required and has no wildcard.** A package sitting in the folder but
199
+ absent from `allow` is discovered, reported, and never imported. Discovery saves
200
+ you the `for` loop; it is the allowlist, not the absence of a scan, that keeps an
201
+ unexpected folder from loading.
202
+ - **The order of `allow` is the order things load,** and the first plugin to claim a
203
+ tool name keeps it. Precedence is something you wrote down rather than something
204
+ the filesystem decided alphabetically.
205
+ - **The directory you pass is the security boundary.** Every manifest must be inside
206
+ it and every entrypoint must resolve to a file inside it. If you point it at a
207
+ directory anyone can write to, none of the rest of this matters.
208
+
209
+ [**The manual**](docs/MANUAL.md) builds this from an empty directory and has an
210
+ entry for [every message preflight can print](docs/MANUAL.md#7-every-message-and-what-to-do-about-it).
211
+
212
+ ### Policy
213
+
214
+ Every default is the strictest value available, so a call passing no `Policy` is the
215
+ safest call you can make.
216
+
217
+ ```python
218
+ from preflight import Policy, ToolRisk, load_plugins
219
+
220
+ result = load_plugins(
221
+ "plugins",
222
+ allow=["example.greeter"],
223
+ policy=Policy(refuse_tool_risks={ToolRisk.DESTRUCTIVE, ToolRisk.FINANCIAL}),
224
+ )
225
+ ```
226
+
227
+ **`Policy` is never loaded from disk, and that is deliberate:** a settings file
228
+ living next to your plugins would be a file a plugin could write, which would put
229
+ your policy on the untrusted side of the boundary it is meant to draw. A host states
230
+ its policy in its own source, where it is reviewable and diffable. To vary it per
231
+ deployment, read *your own* configuration and build a `Policy` from it —
232
+ [preflight inside an agent](docs/MANUAL.md#13-preflight-inside-an-agent).
233
+
234
+ ## The two moments
235
+
236
+ Confusing these is the single easiest way to misread this project:
237
+
238
+ | When | What | Who runs it |
239
+ |---|---|---|
240
+ | **Once**, when you adopt a plugin | `preflight check`, `preflight create`, `preflight try` | you, at a terminal |
241
+ | **Every launch, for the life of the program** | `load_plugins(...)` | your code, automatically |
242
+
243
+ The second row is preflight. The first row is the on-ramp — a way to read what you
244
+ are being asked to trust, and to write down what you will permit, before the gate in
245
+ the second row ever sees it. **None of the terminal commands protect a running
246
+ application, because none of them are running when it is.**
247
+
248
+ ```
249
+ preflight check ./thing # read its manifest and every tool it claims
250
+ preflight create ./thing # write a manifest, when it has none
251
+ preflight try ./sandbox # a working host and plugin, and three ways to break them
252
+ preflight demo # five example plugins, three of them refused
253
+ preflight settings # save the rules, per project and per agent
254
+ ```
255
+
256
+ `check` **imports nothing** — not the plugin, not `importlib`, not even `find_spec`.
257
+ The entrypoint is resolved by path arithmetic against the folder on disk, so no code
258
+ path through the command can cause the inspected package to execute;
259
+ `tests/test_inspect.py` proves it with a tripwire on a package that was genuinely
260
+ importable at the time. It exits `0` when a package would load, `1` when it would be
261
+ refused, and `2` on a bad path — so it drops into CI without anyone reading the
262
+ output. Full reference: [Command line](docs/MANUAL.md#9-command-line-reference).
263
+
264
+ ## Building an agent?
265
+
266
+ The manifest here already speaks that vocabulary — tools, risk levels, permissions —
267
+ because the application this was extracted from needed it to. If you are gating a
268
+ folder of tool packs or skills that your agent imports at startup, that is the same
269
+ problem, and [MANUAL §13](docs/MANUAL.md#13-preflight-inside-an-agent) is the recipe.
270
+
271
+ Two caveats. **MCP servers are usually separate processes speaking a protocol**, and
272
+ this is an in-process Python import gate — wiring it to a process launcher is real
273
+ work this library does not do. And this is a plugin trust boundary that happens to
274
+ suit agent tooling, not an agent framework.
275
+
276
+ ## What it checks
277
+
278
+ Every decision above the line is made from files on disk, and no file executes
279
+ before it has cleared the boundary.
280
+
281
+ | | Check |
282
+ |---|---|
283
+ | 1–4 | The manifest is inside the trusted root, under 256 KiB, valid JSON, and validates against a **closed** schema — an unknown field is a refusal, not a shrug |
284
+ | 5 | `package_id` is on the build's explicit allowlist, and load order follows it |
285
+ | 6–8 | The platform, `visibility` and `release_ring` are ones this build accepts, and a declared tool risk the host refuses stops the package here |
286
+ | 9–11 | No `plugin_id` or tool name collides with something already registered |
287
+ | 12–14 | The entrypoint module — **and every parent package on the way to it** — resolves to a file inside the trusted root, located without being executed |
288
+ | — | ─────── *only now is anything imported* ─────── |
289
+ | 15–17 | The module's real `__file__` is re-checked, the object satisfies the `Plugin` protocol, and the manifest it reports equals the one its file declared |
290
+ | 18–19 | On any refusal the registry is unmodified, and everything handed back is a deep copy |
291
+
292
+ Rows 16–17 are the only ones a package can be excused from, and only by asking in
293
+ writing: an `entrypoint` naming a module and no attribute says *this file is the
294
+ whole description*, preflight adapts the module using it, and there is no second
295
+ statement left to compare against. Nothing else changes, the report says
296
+ `(adapted; manifest not self-reported)` on every such plugin, and
297
+ [MANUAL §6.1](docs/MANUAL.md#61-the-entrypoint-has-two-shapes-and-this-is-the-one-that-matters-here)
298
+ explains when to want it — chiefly, gating a package that has never heard of
299
+ preflight, which was never going to satisfy row 17 anyway.
300
+
301
+ Rows 15–17 are what is left over — checks that *cannot* be made before the import,
302
+ because they are about an object, and there is no object until something has been
303
+ imported.
304
+
305
+ **[The full table names the test that proves each row.](docs/MANUAL.md#14-what-it-checks-in-order--and-the-test-for-each)**
306
+ If you doubt a row, run that test; if a row had no test, it would not be in the table.
307
+
308
+ ## This is not a sandbox
309
+
310
+ Once a plugin is imported it is ordinary Python running in your process. It can read
311
+ your files, open sockets, spawn processes, and monkey-patch you. There is no
312
+ isolation here, no permission enforcement, and no way to take any of it back.
313
+
314
+ **preflight decides *whether* to import. It has no power after that.**
315
+
316
+ The two are complements, not alternatives. Isolation without a gate means running
317
+ untrusted code and hoping the walls hold. A gate without isolation means the code you
318
+ chose to run has the run of the place. Most projects have neither.
319
+
320
+ ## Threat model
321
+
322
+ **Defends against**
323
+
324
+ - A plugin whose code contradicts its manifest — it is refused, and its tools are never registered, so the host never advertises capabilities the gate did not see.
325
+ - A plugin that claims a tool name another plugin already owns. Tool ownership is exclusive, so a plugin cannot shadow another plugin's tool and receive its calls.
326
+ - A plugin that leaks into a build tier it was never meant for — an experimental or internal plugin cannot register in a public build.
327
+ - A manifest inside the trusted root whose entrypoint names a module outside it. This includes dotted entrypoints, where resolving the child would otherwise import the parent as a side effect.
328
+ - A manifest carrying unknown fields, a manifest large enough to be an attack in itself, and a plugin whose id or tool names collide with something already loaded.
329
+
330
+ **Does not defend against**
331
+
332
+ - Anything a plugin does after it loads. See the section above; it is not a footnote.
333
+ - A compromised trusted root. Write access to that directory is write access to your process. Everything here assumes you own it.
334
+ - A malicious or careless host. `PluginRegistry.load_manifest_file` accepts a custom `importer`, and a host that supplies its own has opted out of entrypoint confinement — deliberately, and it owns that decision.
335
+ - Supply-chain compromise of a plugin you allowlisted. preflight checks that a plugin is what it says it is; it has no opinion on whether you should have trusted it.
336
+ - Denial of service. A plugin that hangs at import time hangs your process.
337
+
338
+ **Assumes** you control the trusted root and its contents; that the allowlist is a
339
+ decision rather than a formality; and that the interpreter and standard library are
340
+ trustworthy.
341
+
342
+ ## Install
343
+
344
+ ```
345
+ pip install preflight-gate
346
+ ```
347
+
348
+ **Python 3.11+.** One runtime dependency: `pydantic>=2`.
349
+
350
+ The distribution is `preflight-gate`; the import, the command, and the manifest
351
+ schema are all `preflight`. PyPI's `preflight` is an unrelated Django project last
352
+ released in 2015, so that name was never available. You type the long one once.
353
+
354
+ The examples ship inside the distribution, so `preflight demo` runs from an installed
355
+ copy with no clone. `python -m preflight` works if you would rather not depend on the
356
+ console script. Installing with [pipx](https://pipx.pypa.io) puts the command on your
357
+ PATH regardless of which virtualenv is active.
358
+
359
+ Run the tests from a clean clone:
360
+
361
+ ```
362
+ git clone https://github.com/croresnos/preflight
363
+ cd preflight
364
+ python -m pip install pytest pydantic
365
+ python -m pytest -q
366
+ ```
367
+
368
+ ## More
369
+
370
+ - [**The manual**](docs/MANUAL.md) — install, first host, first refusal, and an entry for every message
371
+ - [Every message and what to do about it](docs/MANUAL.md#7-every-message-and-what-to-do-about-it)
372
+ - [The manifest format](docs/MANUAL.md#10-the-manifest-format), field by field
373
+ - [Questions that come up in practice](docs/MANUAL.md#8-questions-that-come-up-in-practice) — why pydantic, why `__init__.py` is required, why there is no config file, whether `check` can tell you something is safe (it cannot)
374
+ - [Why this exists, and the bug the history keeps](docs/MANUAL.md#15-why-it-exists)
375
+
376
+ ## A note on how this was built
377
+
378
+ Built with AI assistance. The threat model, the confinement design, and the decision
379
+ to fail closed on any module that cannot be proven in-tree are mine — as is every
380
+ line I would be asked to defend.
381
+
382
+ ## License
383
+
384
+ MIT. See [LICENSE](LICENSE).