preflight-gate 0.7.0__py3-none-any.whl
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.
- preflight/__init__.py +112 -0
- preflight/__main__.py +5 -0
- preflight/_examples/plugins/collider/__init__.py +8 -0
- preflight/_examples/plugins/collider/manifest.json +22 -0
- preflight/_examples/plugins/collider/plugin.py +32 -0
- preflight/_examples/plugins/greeter/__init__.py +9 -0
- preflight/_examples/plugins/greeter/manifest.json +22 -0
- preflight/_examples/plugins/greeter/plugin.py +36 -0
- preflight/_examples/plugins/impostor/__init__.py +10 -0
- preflight/_examples/plugins/impostor/manifest.json +28 -0
- preflight/_examples/plugins/impostor/plugin.py +57 -0
- preflight/_examples/plugins/janitor/__init__.py +12 -0
- preflight/_examples/plugins/janitor/manifest.json +22 -0
- preflight/_examples/plugins/janitor/plugin.py +43 -0
- preflight/_examples/plugins/trespasser/README.md +17 -0
- preflight/_examples/plugins/trespasser/manifest.json +15 -0
- preflight/cli.py +1177 -0
- preflight/inspect.py +824 -0
- preflight/load.py +292 -0
- preflight/manifest.py +431 -0
- preflight/py.typed +0 -0
- preflight/registry.py +674 -0
- preflight/settings.py +531 -0
- preflight_gate-0.7.0.dist-info/METADATA +384 -0
- preflight_gate-0.7.0.dist-info/RECORD +28 -0
- preflight_gate-0.7.0.dist-info/WHEEL +4 -0
- preflight_gate-0.7.0.dist-info/entry_points.txt +2 -0
- preflight_gate-0.7.0.dist-info/licenses/LICENSE +21 -0
preflight/__init__.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""preflight -- decide whether a plugin may load, before any of its code runs.
|
|
2
|
+
|
|
3
|
+
This goes inside your program and runs every time it starts, deciding which
|
|
4
|
+
plugins are allowed to load::
|
|
5
|
+
|
|
6
|
+
from preflight import Policy, ToolRisk, load_plugins
|
|
7
|
+
|
|
8
|
+
result = load_plugins(
|
|
9
|
+
"plugins",
|
|
10
|
+
allow=["acme.weather"], # required, and there is no wildcard
|
|
11
|
+
policy=Policy(refuse_tool_risks={ToolRisk.DESTRUCTIVE}),
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
Packages in the directory but missing from ``allow`` are discovered, reported,
|
|
15
|
+
and never imported. Every decision is made against the plugin's manifest file,
|
|
16
|
+
so the import is what a plugin gets for passing rather than the first thing that
|
|
17
|
+
happens to it.
|
|
18
|
+
|
|
19
|
+
There is also a command line -- ``preflight check`` and ``preflight create`` -- for
|
|
20
|
+
the separate moment where you are adopting a package you did not write and want
|
|
21
|
+
to read its paperwork, or write down what you will permit it to do. That is the
|
|
22
|
+
on-ramp to the gate above, not a substitute for it.
|
|
23
|
+
|
|
24
|
+
This is not a sandbox and not a scanner. Once a plugin is imported it is
|
|
25
|
+
ordinary Python with the full run of the process, and preflight has no power
|
|
26
|
+
after that. It never reads a plugin's code, so it cannot tell you whether the
|
|
27
|
+
code matches what the manifest claims.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
# --- the front door: what almost every host needs -------------------------
|
|
31
|
+
# --- inspection: reading a package without running it ---------------------
|
|
32
|
+
from preflight.inspect import (
|
|
33
|
+
Inspection,
|
|
34
|
+
format_inspection,
|
|
35
|
+
inspect_directory,
|
|
36
|
+
inspect_package,
|
|
37
|
+
)
|
|
38
|
+
from preflight.load import (
|
|
39
|
+
MANIFEST_NAME,
|
|
40
|
+
LoadReport,
|
|
41
|
+
Outcome,
|
|
42
|
+
Policy,
|
|
43
|
+
load_plugins,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# --- the manifest schema: what a plugin author writes ---------------------
|
|
47
|
+
# --- release tiers: opt-in, and unnecessary for a single-tier host ---------
|
|
48
|
+
from preflight.manifest import (
|
|
49
|
+
Health,
|
|
50
|
+
HealthState,
|
|
51
|
+
Migration,
|
|
52
|
+
Platform,
|
|
53
|
+
Plugin,
|
|
54
|
+
PluginManifest,
|
|
55
|
+
PluginPackageManifest,
|
|
56
|
+
ReleaseRing,
|
|
57
|
+
Tool,
|
|
58
|
+
ToolRisk,
|
|
59
|
+
ToolSurface,
|
|
60
|
+
UIContribution,
|
|
61
|
+
Visibility,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# --- the gate itself, and its refusal -------------------------------------
|
|
65
|
+
from preflight.registry import (
|
|
66
|
+
Edition,
|
|
67
|
+
PluginRegistry,
|
|
68
|
+
PluginRejected,
|
|
69
|
+
RegisteredPlugin,
|
|
70
|
+
development_build,
|
|
71
|
+
internal_build,
|
|
72
|
+
public_build,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
__all__ = [
|
|
76
|
+
# front door
|
|
77
|
+
"load_plugins",
|
|
78
|
+
"LoadReport",
|
|
79
|
+
"Outcome",
|
|
80
|
+
"Policy",
|
|
81
|
+
"MANIFEST_NAME",
|
|
82
|
+
# inspection
|
|
83
|
+
"inspect_package",
|
|
84
|
+
"inspect_directory",
|
|
85
|
+
"format_inspection",
|
|
86
|
+
"Inspection",
|
|
87
|
+
# manifest schema
|
|
88
|
+
"PluginPackageManifest",
|
|
89
|
+
"PluginManifest",
|
|
90
|
+
"Plugin",
|
|
91
|
+
"Tool",
|
|
92
|
+
"ToolRisk",
|
|
93
|
+
"ToolSurface",
|
|
94
|
+
"Platform",
|
|
95
|
+
"Health",
|
|
96
|
+
"HealthState",
|
|
97
|
+
"Migration",
|
|
98
|
+
"UIContribution",
|
|
99
|
+
# the gate
|
|
100
|
+
"PluginRegistry",
|
|
101
|
+
"PluginRejected",
|
|
102
|
+
"RegisteredPlugin",
|
|
103
|
+
# release tiers (opt-in)
|
|
104
|
+
"Edition",
|
|
105
|
+
"Visibility",
|
|
106
|
+
"ReleaseRing",
|
|
107
|
+
"public_build",
|
|
108
|
+
"internal_build",
|
|
109
|
+
"development_build",
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
__version__ = "0.7.0"
|
preflight/__main__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""A plugin that claims a tool name ``greeter`` already owns.
|
|
2
|
+
|
|
3
|
+
The print below never appears in the output of ``examples/host.py``. The tool
|
|
4
|
+
name collision is visible in the manifest file, so the refusal happens while this
|
|
5
|
+
package is still inert text on disk.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
print(" [collider] top-level plugin code is executing -- THIS SHOULD NEVER PRINT")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": "1.0",
|
|
3
|
+
"package_id": "example.collider",
|
|
4
|
+
"core_api_version": "1.0",
|
|
5
|
+
"visibility": "public",
|
|
6
|
+
"release_ring": "stable",
|
|
7
|
+
"entrypoint": "collider.plugin:create_plugin",
|
|
8
|
+
"plugin": {
|
|
9
|
+
"schema_version": "1.0",
|
|
10
|
+
"plugin_id": "collider",
|
|
11
|
+
"name": "Collider",
|
|
12
|
+
"module_version": "1.0.0",
|
|
13
|
+
"tools": [
|
|
14
|
+
{
|
|
15
|
+
"name": "greeter.hello",
|
|
16
|
+
"risk": "read",
|
|
17
|
+
"surface": "backend",
|
|
18
|
+
"description": "A second, conflicting owner of this tool name."
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Never imported. Kept complete so the refusal cannot be blamed on a broken plugin.
|
|
2
|
+
|
|
3
|
+
This plugin is well formed. It would load without complaint if ``greeter`` were
|
|
4
|
+
not already registered. The only thing wrong with it is the name it claims.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from preflight import PluginManifest, Tool, ToolRisk
|
|
10
|
+
|
|
11
|
+
_MANIFEST = PluginManifest(
|
|
12
|
+
plugin_id="collider",
|
|
13
|
+
name="Collider",
|
|
14
|
+
module_version="1.0.0",
|
|
15
|
+
tools=[
|
|
16
|
+
Tool(
|
|
17
|
+
name="greeter.hello",
|
|
18
|
+
risk=ToolRisk.READ,
|
|
19
|
+
description="A second, conflicting owner of this tool name.",
|
|
20
|
+
)
|
|
21
|
+
],
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Collider:
|
|
26
|
+
@property
|
|
27
|
+
def manifest(self) -> PluginManifest:
|
|
28
|
+
return _MANIFEST
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def create_plugin() -> Collider:
|
|
32
|
+
return Collider()
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""A plugin that is exactly what its manifest says it is.
|
|
2
|
+
|
|
3
|
+
The print below is a tripwire. It is the earliest code in this package that can
|
|
4
|
+
possibly run, and it runs only if preflight decided this plugin was allowed to
|
|
5
|
+
be imported. Compare with ``collider``, whose identical line never appears in
|
|
6
|
+
the output of ``examples/host.py``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
print(" [greeter] top-level plugin code is executing")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": "1.0",
|
|
3
|
+
"package_id": "example.greeter",
|
|
4
|
+
"core_api_version": "1.0",
|
|
5
|
+
"visibility": "public",
|
|
6
|
+
"release_ring": "stable",
|
|
7
|
+
"entrypoint": "greeter.plugin:create_plugin",
|
|
8
|
+
"plugin": {
|
|
9
|
+
"schema_version": "1.0",
|
|
10
|
+
"plugin_id": "greeter",
|
|
11
|
+
"name": "Greeter",
|
|
12
|
+
"module_version": "1.0.0",
|
|
13
|
+
"tools": [
|
|
14
|
+
{
|
|
15
|
+
"name": "greeter.hello",
|
|
16
|
+
"risk": "read",
|
|
17
|
+
"surface": "backend",
|
|
18
|
+
"description": "Return a greeting for a name."
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""The greeter's implementation and the manifest it reports about itself."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from preflight import PluginManifest, Tool, ToolRisk
|
|
6
|
+
|
|
7
|
+
#: Must equal the ``plugin`` object in ``manifest.json`` field for field. The
|
|
8
|
+
#: registry validates the manifest file first, then requires the loaded object to
|
|
9
|
+
#: report the same thing. See ``impostor`` for what happens when it does not.
|
|
10
|
+
_MANIFEST = PluginManifest(
|
|
11
|
+
plugin_id="greeter",
|
|
12
|
+
name="Greeter",
|
|
13
|
+
module_version="1.0.0",
|
|
14
|
+
tools=[
|
|
15
|
+
Tool(
|
|
16
|
+
name="greeter.hello",
|
|
17
|
+
risk=ToolRisk.READ,
|
|
18
|
+
description="Return a greeting for a name.",
|
|
19
|
+
)
|
|
20
|
+
],
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Greeter:
|
|
25
|
+
"""The entire plugin ABI is the ``manifest`` property. The rest is this plugin's own."""
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def manifest(self) -> PluginManifest:
|
|
29
|
+
return _MANIFEST
|
|
30
|
+
|
|
31
|
+
def hello(self, who: str) -> str:
|
|
32
|
+
return f"Hello, {who}."
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def create_plugin() -> Greeter:
|
|
36
|
+
return Greeter()
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""A plugin whose loaded object reports a different manifest than it declared.
|
|
2
|
+
|
|
3
|
+
The print below *does* appear in the output of ``examples/host.py``, and that is
|
|
4
|
+
the honest lesson of this example. Nothing in the manifest file is wrong, so
|
|
5
|
+
every pre-import check passes and the import happens. The mismatch is only
|
|
6
|
+
visible once there is an object to ask, so this is the one refusal in preflight
|
|
7
|
+
that necessarily lands after the plugin's code has run.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
print(" [impostor] top-level plugin code is executing")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": "1.0",
|
|
3
|
+
"package_id": "example.impostor",
|
|
4
|
+
"core_api_version": "1.0",
|
|
5
|
+
"visibility": "public",
|
|
6
|
+
"release_ring": "stable",
|
|
7
|
+
"entrypoint": "impostor.plugin:create_plugin",
|
|
8
|
+
"plugin": {
|
|
9
|
+
"schema_version": "1.0",
|
|
10
|
+
"plugin_id": "impostor",
|
|
11
|
+
"name": "Profile Reader",
|
|
12
|
+
"module_version": "1.0.0",
|
|
13
|
+
"tools": [
|
|
14
|
+
{
|
|
15
|
+
"name": "impostor.read_profile",
|
|
16
|
+
"risk": "read",
|
|
17
|
+
"surface": "backend",
|
|
18
|
+
"description": "Read the current user's profile."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"name": "impostor.read_settings",
|
|
22
|
+
"risk": "read",
|
|
23
|
+
"surface": "backend",
|
|
24
|
+
"description": "Read the current user's settings."
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Declares two read-only tools on paper; reports a third one at runtime.
|
|
2
|
+
|
|
3
|
+
Be precise about what this demonstrates. preflight compares two *descriptions* of
|
|
4
|
+
the plugin: the one in ``manifest.json`` and the one the loaded object returns
|
|
5
|
+
from its ``manifest`` property. It does not read this file, analyse the class
|
|
6
|
+
below, or check that ``purge_all_records`` does anything destructive -- the name
|
|
7
|
+
is a label chosen by whoever wrote the plugin.
|
|
8
|
+
|
|
9
|
+
What the check is actually worth: a host reads ``registry.available()`` to build
|
|
10
|
+
its permission prompts, its tool list, and its UI. Without this comparison a
|
|
11
|
+
plugin could be approved on the strength of one manifest and then hand the host a
|
|
12
|
+
different one, so the host would end up advertising capabilities the gate never
|
|
13
|
+
saw. The two descriptions must agree or the plugin does not register.
|
|
14
|
+
|
|
15
|
+
What it is not worth: nothing here constrains behaviour. A plugin that reports
|
|
16
|
+
its manifest accurately and then deletes your database loads without complaint.
|
|
17
|
+
Deciding what a loaded plugin may *do* is a different problem, and preflight does
|
|
18
|
+
not solve it.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from preflight import PluginManifest, Tool, ToolRisk
|
|
24
|
+
|
|
25
|
+
#: Note the third entry. ``manifest.json`` declares only the two read tools.
|
|
26
|
+
_REPORTED_MANIFEST = PluginManifest(
|
|
27
|
+
plugin_id="impostor",
|
|
28
|
+
name="Profile Reader",
|
|
29
|
+
module_version="1.0.0",
|
|
30
|
+
tools=[
|
|
31
|
+
Tool(
|
|
32
|
+
name="impostor.read_profile",
|
|
33
|
+
risk=ToolRisk.READ,
|
|
34
|
+
description="Read the current user's profile.",
|
|
35
|
+
),
|
|
36
|
+
Tool(
|
|
37
|
+
name="impostor.read_settings",
|
|
38
|
+
risk=ToolRisk.READ,
|
|
39
|
+
description="Read the current user's settings.",
|
|
40
|
+
),
|
|
41
|
+
Tool(
|
|
42
|
+
name="impostor.purge_all_records",
|
|
43
|
+
risk=ToolRisk.DESTRUCTIVE,
|
|
44
|
+
description="Undeclared. The manifest file does not mention this tool.",
|
|
45
|
+
),
|
|
46
|
+
],
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Impostor:
|
|
51
|
+
@property
|
|
52
|
+
def manifest(self) -> PluginManifest:
|
|
53
|
+
return _REPORTED_MANIFEST
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def create_plugin() -> Impostor:
|
|
57
|
+
return Impostor()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""A plugin with nothing wrong with it, which a host may still refuse.
|
|
2
|
+
|
|
3
|
+
Its paperwork is faultless and it declares exactly what it does. The only
|
|
4
|
+
question it raises is whether you want a plugin that deletes things, and that
|
|
5
|
+
question has no answer preflight can supply -- it is the host's to answer.
|
|
6
|
+
|
|
7
|
+
Run ``preflight demo`` and this loads. Run ``preflight demo --refuse
|
|
8
|
+
destructive`` and the tripwire below never prints, because the same manifest
|
|
9
|
+
met a host that had said no.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
print(" [janitor] top-level plugin code is executing")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": "1.0",
|
|
3
|
+
"package_id": "example.janitor",
|
|
4
|
+
"core_api_version": "1.0",
|
|
5
|
+
"visibility": "public",
|
|
6
|
+
"release_ring": "stable",
|
|
7
|
+
"entrypoint": "janitor.plugin:create_plugin",
|
|
8
|
+
"plugin": {
|
|
9
|
+
"schema_version": "1.0",
|
|
10
|
+
"plugin_id": "janitor",
|
|
11
|
+
"name": "Janitor",
|
|
12
|
+
"module_version": "1.0.0",
|
|
13
|
+
"tools": [
|
|
14
|
+
{
|
|
15
|
+
"name": "janitor.purge_cache",
|
|
16
|
+
"risk": "destructive",
|
|
17
|
+
"surface": "backend",
|
|
18
|
+
"description": "Delete cached files older than thirty days."
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""The janitor's implementation and the manifest it reports about itself.
|
|
2
|
+
|
|
3
|
+
Compare with ``impostor``. Both packages end up refused under ``preflight demo
|
|
4
|
+
--refuse destructive``, but only one of them is refused *by the flag*: this one
|
|
5
|
+
declares its destructive tool in ``manifest.json``, so the gate sees it while
|
|
6
|
+
the package is still inert on disk. impostor declares two read-only tools and
|
|
7
|
+
produces the destructive one at runtime, where the flag cannot reach it.
|
|
8
|
+
|
|
9
|
+
That is the difference between a permission system and a detection system.
|
|
10
|
+
preflight enforces what a package declared; it never looks for what a package
|
|
11
|
+
concealed.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from preflight import PluginManifest, Tool, ToolRisk
|
|
17
|
+
|
|
18
|
+
#: Must equal the ``plugin`` object in ``manifest.json`` field for field.
|
|
19
|
+
_MANIFEST = PluginManifest(
|
|
20
|
+
plugin_id="janitor",
|
|
21
|
+
name="Janitor",
|
|
22
|
+
module_version="1.0.0",
|
|
23
|
+
tools=[
|
|
24
|
+
Tool(
|
|
25
|
+
name="janitor.purge_cache",
|
|
26
|
+
risk=ToolRisk.DESTRUCTIVE,
|
|
27
|
+
description="Delete cached files older than thirty days.",
|
|
28
|
+
)
|
|
29
|
+
],
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Janitor:
|
|
34
|
+
@property
|
|
35
|
+
def manifest(self) -> PluginManifest:
|
|
36
|
+
return _MANIFEST
|
|
37
|
+
|
|
38
|
+
def purge_cache(self, older_than_days: int = 30) -> str:
|
|
39
|
+
return f"would delete cached files older than {older_than_days} days"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def create_plugin() -> Janitor:
|
|
43
|
+
return Janitor()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# trespasser
|
|
2
|
+
|
|
3
|
+
This directory contains a manifest and no Python, which is the point.
|
|
4
|
+
|
|
5
|
+
`manifest.json` sits inside the trusted plugin root and passes every check that
|
|
6
|
+
reads the manifest file: the package id is canonical, the entrypoint has the
|
|
7
|
+
right shape, the visibility and ring are ones a public build accepts. The only
|
|
8
|
+
thing wrong with it is where its entrypoint points -- `json` is the standard
|
|
9
|
+
library, far outside the trusted root.
|
|
10
|
+
|
|
11
|
+
Shape is not location. A manifest is allowed to *name* any module; whether that
|
|
12
|
+
module may be imported is decided separately, by resolving it to a file on disk
|
|
13
|
+
before the import happens.
|
|
14
|
+
|
|
15
|
+
`json:loads` is a harmless stand-in for the general case. A real attempt would
|
|
16
|
+
name something with a side effect at import time, and the same check refuses it
|
|
17
|
+
for the same reason.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": "1.0",
|
|
3
|
+
"package_id": "example.trespasser",
|
|
4
|
+
"core_api_version": "1.0",
|
|
5
|
+
"visibility": "public",
|
|
6
|
+
"release_ring": "stable",
|
|
7
|
+
"entrypoint": "json:loads",
|
|
8
|
+
"plugin": {
|
|
9
|
+
"schema_version": "1.0",
|
|
10
|
+
"plugin_id": "trespasser",
|
|
11
|
+
"name": "Trespasser",
|
|
12
|
+
"module_version": "1.0.0",
|
|
13
|
+
"tools": []
|
|
14
|
+
}
|
|
15
|
+
}
|