nutria-plugin 0.0.1a0__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.
- nutria_plugin/__init__.py +54 -0
- nutria_plugin/bundle.py +172 -0
- nutria_plugin/cli.py +157 -0
- nutria_plugin/manifest.py +182 -0
- nutria_plugin/packaging.py +202 -0
- nutria_plugin/signing.py +142 -0
- nutria_plugin-0.0.1a0.dist-info/METADATA +180 -0
- nutria_plugin-0.0.1a0.dist-info/RECORD +10 -0
- nutria_plugin-0.0.1a0.dist-info/WHEEL +4 -0
- nutria_plugin-0.0.1a0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""nutria_plugin — Nutria Plugin SDK — v0.0.1-alpha
|
|
2
|
+
|
|
3
|
+
Public API
|
|
4
|
+
----------
|
|
5
|
+
Models:
|
|
6
|
+
PluginManifest, PluginPaths, PluginCompatibility, PluginScope, PluginRuntimeType
|
|
7
|
+
|
|
8
|
+
Bundle operations:
|
|
9
|
+
load_plugin_bundle, extract_plugin_bundle, validate_zip
|
|
10
|
+
|
|
11
|
+
Packaging:
|
|
12
|
+
scaffold_plugin, pack_plugin, validate_plugin_dir, PackagingError
|
|
13
|
+
|
|
14
|
+
Signing:
|
|
15
|
+
generate_keypair, sign_manifest, verify_manifest, SignatureStatus
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
__version__ = "0.0.1-alpha"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
from .manifest import (
|
|
22
|
+
PluginCompatibility,
|
|
23
|
+
PluginManifest,
|
|
24
|
+
PluginPaths,
|
|
25
|
+
PluginRuntimeType,
|
|
26
|
+
PluginScope,
|
|
27
|
+
)
|
|
28
|
+
from .packaging import PackagingError, pack_plugin, scaffold_plugin, validate_plugin_dir
|
|
29
|
+
from .signing import SignatureStatus, generate_keypair, sign_manifest, verify_manifest
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"__version__",
|
|
33
|
+
# Manifest
|
|
34
|
+
"PluginManifest",
|
|
35
|
+
"PluginPaths",
|
|
36
|
+
"PluginCompatibility",
|
|
37
|
+
"PluginScope",
|
|
38
|
+
"PluginRuntimeType",
|
|
39
|
+
# Bundle
|
|
40
|
+
"PluginBundleError",
|
|
41
|
+
"load_plugin_bundle",
|
|
42
|
+
"extract_plugin_bundle",
|
|
43
|
+
"validate_zip",
|
|
44
|
+
# Packaging
|
|
45
|
+
"PackagingError",
|
|
46
|
+
"scaffold_plugin",
|
|
47
|
+
"pack_plugin",
|
|
48
|
+
"validate_plugin_dir",
|
|
49
|
+
# Signing
|
|
50
|
+
"SignatureStatus",
|
|
51
|
+
"generate_keypair",
|
|
52
|
+
"sign_manifest",
|
|
53
|
+
"verify_manifest",
|
|
54
|
+
]
|
nutria_plugin/bundle.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Plugin bundle validation and extraction.
|
|
3
|
+
|
|
4
|
+
This module handles the ZIP lifecycle:
|
|
5
|
+
- validate_zip() — check structure/size/allowlist, no I/O side-effects
|
|
6
|
+
- load_plugin_bundle() — parse and return the manifest without extracting
|
|
7
|
+
- extract_plugin_bundle() — extract into a target directory
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import io
|
|
13
|
+
import zipfile
|
|
14
|
+
from pathlib import Path, PurePosixPath
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from .manifest import PluginManifest
|
|
18
|
+
|
|
19
|
+
MANIFEST_FILENAME = "plugin.json"
|
|
20
|
+
MAX_BUNDLE_SIZE_BYTES = 20 * 1024 * 1024 # 20 MB compressed
|
|
21
|
+
MAX_UNCOMPRESSED_SIZE_BYTES = 100 * 1024 * 1024 # 100 MB total uncompressed
|
|
22
|
+
|
|
23
|
+
# Allowlist — only these extensions are accepted inside a plugin ZIP.
|
|
24
|
+
# Files with no extension (e.g. README, CHANGELOG) are also allowed.
|
|
25
|
+
ALLOWED_EXTENSIONS = {
|
|
26
|
+
".json",
|
|
27
|
+
".md",
|
|
28
|
+
".txt",
|
|
29
|
+
".html",
|
|
30
|
+
".htm",
|
|
31
|
+
".png",
|
|
32
|
+
".jpg",
|
|
33
|
+
".jpeg",
|
|
34
|
+
".gif",
|
|
35
|
+
".svg",
|
|
36
|
+
".ico",
|
|
37
|
+
".xml",
|
|
38
|
+
".wsdl", # SOAP specs
|
|
39
|
+
".yaml",
|
|
40
|
+
".yml",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# Kept for backwards-compatible imports; no longer used in core validation.
|
|
44
|
+
BLOCKED_PATTERNS = {".py", ".js", ".ts", ".sh", ".exe", ".dll", ".so", ".whl", ".tar"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PluginBundleError(Exception):
|
|
48
|
+
"""Raised when a plugin ZIP is invalid, corrupt, or unsafe."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _safe_zip_path(name: str) -> PurePosixPath:
|
|
52
|
+
"""Return a PurePosixPath for the ZIP entry or raise PluginBundleError on path traversal.
|
|
53
|
+
|
|
54
|
+
Checks the raw string for traversal before PurePosixPath normalisation strips
|
|
55
|
+
any context (e.g. PurePosixPath("./x").parts == ("x",) — the '.' is gone).
|
|
56
|
+
"""
|
|
57
|
+
if "//" in name or name.startswith("/"):
|
|
58
|
+
raise PluginBundleError(f"zip entry with absolute path is not allowed: {name!r}")
|
|
59
|
+
path = PurePosixPath(name)
|
|
60
|
+
if path.is_absolute():
|
|
61
|
+
raise PluginBundleError(f"zip entry with absolute path is not allowed: {name!r}")
|
|
62
|
+
if ".." in path.parts:
|
|
63
|
+
raise PluginBundleError(f"zip entry with path traversal is not allowed: {name!r}")
|
|
64
|
+
return path
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def validate_zip(data: bytes) -> list[str]:
|
|
68
|
+
"""Validate a plugin ZIP and return a list of validation errors (empty = valid).
|
|
69
|
+
|
|
70
|
+
This does NOT extract files to disk — it only reads the ZIP in memory.
|
|
71
|
+
"""
|
|
72
|
+
errors: list[str] = []
|
|
73
|
+
|
|
74
|
+
if len(data) > MAX_BUNDLE_SIZE_BYTES:
|
|
75
|
+
errors.append(
|
|
76
|
+
f"bundle exceeds max size {MAX_BUNDLE_SIZE_BYTES // (1024 * 1024)} MB"
|
|
77
|
+
)
|
|
78
|
+
return errors # no point continuing size checks
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
|
82
|
+
names = zf.namelist()
|
|
83
|
+
|
|
84
|
+
# 1. manifest must be present at the top level
|
|
85
|
+
if MANIFEST_FILENAME not in names:
|
|
86
|
+
errors.append(f"{MANIFEST_FILENAME} not found in bundle root")
|
|
87
|
+
|
|
88
|
+
# 2. zip bomb: check total uncompressed size before any extraction
|
|
89
|
+
total_uncompressed = sum(info.file_size for info in zf.infolist())
|
|
90
|
+
if total_uncompressed > MAX_UNCOMPRESSED_SIZE_BYTES:
|
|
91
|
+
errors.append(
|
|
92
|
+
f"bundle uncompressed content exceeds max size "
|
|
93
|
+
f"{MAX_UNCOMPRESSED_SIZE_BYTES // (1024 * 1024)} MB"
|
|
94
|
+
)
|
|
95
|
+
return errors
|
|
96
|
+
|
|
97
|
+
for name in names:
|
|
98
|
+
try:
|
|
99
|
+
path = _safe_zip_path(name)
|
|
100
|
+
except PluginBundleError as exc:
|
|
101
|
+
errors.append(str(exc))
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
suffix = path.suffix.lower()
|
|
105
|
+
|
|
106
|
+
# 3. allowlist: only known-safe extensions (files with no extension are allowed)
|
|
107
|
+
if suffix and suffix not in ALLOWED_EXTENSIONS:
|
|
108
|
+
errors.append(f"file type not allowed in plugin bundle: {name!r}")
|
|
109
|
+
|
|
110
|
+
# 4. no hidden files
|
|
111
|
+
if any(part.startswith(".") for part in path.parts):
|
|
112
|
+
errors.append(f"hidden file/directory not allowed in plugin bundle: {name!r}")
|
|
113
|
+
|
|
114
|
+
except zipfile.BadZipFile as exc:
|
|
115
|
+
errors.append(f"invalid zip file: {exc}")
|
|
116
|
+
|
|
117
|
+
return errors
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def load_plugin_bundle(data: bytes) -> PluginManifest:
|
|
121
|
+
"""Parse and return the PluginManifest from ZIP bytes without extracting to disk.
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
PluginBundleError: if the ZIP is invalid or the manifest cannot be parsed.
|
|
125
|
+
"""
|
|
126
|
+
errors = validate_zip(data)
|
|
127
|
+
if errors:
|
|
128
|
+
raise PluginBundleError("; ".join(errors))
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
|
132
|
+
raw = zf.read(MANIFEST_FILENAME)
|
|
133
|
+
except KeyError:
|
|
134
|
+
raise PluginBundleError(f"{MANIFEST_FILENAME} missing from bundle")
|
|
135
|
+
except zipfile.BadZipFile as exc:
|
|
136
|
+
raise PluginBundleError(f"invalid zip file: {exc}")
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
return PluginManifest.from_json_bytes(raw)
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
raise PluginBundleError(f"invalid {MANIFEST_FILENAME}: {exc}") from exc
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def extract_plugin_bundle(data: bytes, target_dir: Path) -> PluginManifest:
|
|
145
|
+
"""Extract a plugin ZIP into target_dir and return the parsed manifest.
|
|
146
|
+
|
|
147
|
+
target_dir must already exist. Any existing contents are left in place;
|
|
148
|
+
the caller is responsible for cleanup on failure.
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
PluginBundleError: if validation fails or extraction encounters path traversal.
|
|
152
|
+
"""
|
|
153
|
+
manifest = load_plugin_bundle(data) # validates first
|
|
154
|
+
|
|
155
|
+
target_dir = target_dir.resolve()
|
|
156
|
+
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
|
157
|
+
for info in zf.infolist():
|
|
158
|
+
if info.is_dir():
|
|
159
|
+
continue
|
|
160
|
+
safe_path = _safe_zip_path(info.filename)
|
|
161
|
+
dest = target_dir / safe_path
|
|
162
|
+
# double-check after resolving symlinks
|
|
163
|
+
try:
|
|
164
|
+
dest.resolve().relative_to(target_dir)
|
|
165
|
+
except ValueError:
|
|
166
|
+
raise PluginBundleError(
|
|
167
|
+
f"path traversal detected during extraction: {info.filename!r}"
|
|
168
|
+
)
|
|
169
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
170
|
+
dest.write_bytes(zf.read(info.filename))
|
|
171
|
+
|
|
172
|
+
return manifest
|
nutria_plugin/cli.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""
|
|
2
|
+
nutria-plugin CLI
|
|
3
|
+
|
|
4
|
+
Commands:
|
|
5
|
+
keygen Generate an ECDSA P-256 key pair for manifest signing
|
|
6
|
+
sign Sign a plugin manifest in-place
|
|
7
|
+
new Scaffold a new plugin directory
|
|
8
|
+
pack Validate and pack a plugin directory into a ZIP bundle
|
|
9
|
+
validate Validate a plugin directory without packing
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _cmd_keygen(args: argparse.Namespace) -> int:
|
|
21
|
+
from .signing import generate_keypair
|
|
22
|
+
|
|
23
|
+
out_stem = Path(args.out)
|
|
24
|
+
# Reject path traversal in the output stem
|
|
25
|
+
try:
|
|
26
|
+
resolved = out_stem.resolve()
|
|
27
|
+
cwd = Path.cwd().resolve()
|
|
28
|
+
resolved.relative_to(cwd)
|
|
29
|
+
except ValueError:
|
|
30
|
+
print(f"error: --out path must be within the current directory", file=sys.stderr)
|
|
31
|
+
return 1
|
|
32
|
+
|
|
33
|
+
private_pem, public_pem = generate_keypair()
|
|
34
|
+
priv_file = out_stem.with_suffix(".pem")
|
|
35
|
+
pub_file = out_stem.with_suffix(".pub.pem")
|
|
36
|
+
priv_file.write_text(private_pem, encoding="utf-8")
|
|
37
|
+
pub_file.write_text(public_pem, encoding="utf-8")
|
|
38
|
+
print(f"Private key: {priv_file}")
|
|
39
|
+
print(f"Public key: {pub_file}")
|
|
40
|
+
print()
|
|
41
|
+
print("Add the public key to NUTRIA_PLUGIN_TRUSTED_KEYS:")
|
|
42
|
+
print(json.dumps([public_pem]))
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _cmd_sign(args: argparse.Namespace) -> int:
|
|
47
|
+
from .signing import sign_manifest
|
|
48
|
+
|
|
49
|
+
manifest_path = Path(args.manifest)
|
|
50
|
+
if not manifest_path.exists():
|
|
51
|
+
print(f"error: {manifest_path} not found", file=sys.stderr)
|
|
52
|
+
return 1
|
|
53
|
+
|
|
54
|
+
key_path = Path(args.key)
|
|
55
|
+
if not key_path.exists():
|
|
56
|
+
print(f"error: {key_path} not found", file=sys.stderr)
|
|
57
|
+
return 1
|
|
58
|
+
|
|
59
|
+
private_pem = key_path.read_text(encoding="utf-8")
|
|
60
|
+
raw_dict = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
61
|
+
sig_hex = sign_manifest(raw_dict, private_pem)
|
|
62
|
+
raw_dict["signature"] = sig_hex
|
|
63
|
+
manifest_path.write_text(json.dumps(raw_dict, indent=2) + "\n", encoding="utf-8")
|
|
64
|
+
print(f"Signed: {manifest_path}")
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _cmd_new(args: argparse.Namespace) -> int:
|
|
69
|
+
from .packaging import PackagingError, scaffold_plugin
|
|
70
|
+
|
|
71
|
+
target = Path(args.dir or args.id)
|
|
72
|
+
try:
|
|
73
|
+
scaffold_plugin(target, plugin_id=args.id, name=args.name)
|
|
74
|
+
print(f"Created plugin scaffold in: {target}")
|
|
75
|
+
return 0
|
|
76
|
+
except PackagingError as exc:
|
|
77
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
78
|
+
return 1
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _cmd_pack(args: argparse.Namespace) -> int:
|
|
82
|
+
from .packaging import PackagingError, pack_plugin
|
|
83
|
+
|
|
84
|
+
plugin_dir = Path(args.dir)
|
|
85
|
+
output = Path(args.output) if args.output else None
|
|
86
|
+
|
|
87
|
+
private_pem: str | None = None
|
|
88
|
+
if args.key:
|
|
89
|
+
key_path = Path(args.key)
|
|
90
|
+
if not key_path.exists():
|
|
91
|
+
print(f"error: {key_path} not found", file=sys.stderr)
|
|
92
|
+
return 1
|
|
93
|
+
private_pem = key_path.read_text(encoding="utf-8")
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
out_path = pack_plugin(plugin_dir, output, sign=bool(args.key), private_key_pem=private_pem)
|
|
97
|
+
print(f"Packed: {out_path}")
|
|
98
|
+
return 0
|
|
99
|
+
except PackagingError as exc:
|
|
100
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
101
|
+
return 1
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _cmd_validate(args: argparse.Namespace) -> int:
|
|
105
|
+
from .packaging import validate_plugin_dir
|
|
106
|
+
|
|
107
|
+
plugin_dir = Path(args.dir)
|
|
108
|
+
errors = validate_plugin_dir(plugin_dir)
|
|
109
|
+
if errors:
|
|
110
|
+
print("Validation errors:")
|
|
111
|
+
for err in errors:
|
|
112
|
+
print(f" - {err}")
|
|
113
|
+
return 1
|
|
114
|
+
print("OK")
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def main(argv: list[str] | None = None) -> int:
|
|
119
|
+
parser = argparse.ArgumentParser(
|
|
120
|
+
prog="nutria-plugin",
|
|
121
|
+
description="Nutria plugin SDK CLI",
|
|
122
|
+
)
|
|
123
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
124
|
+
|
|
125
|
+
p_keygen = sub.add_parser("keygen", help="Generate an ECDSA P-256 key pair")
|
|
126
|
+
p_keygen.add_argument("--out", default="nutria-plugin", help="Output file stem (no extension)")
|
|
127
|
+
|
|
128
|
+
p_sign = sub.add_parser("sign", help="Sign a plugin manifest in-place")
|
|
129
|
+
p_sign.add_argument("manifest", nargs="?", default="plugin.json", help="Path to plugin.json")
|
|
130
|
+
p_sign.add_argument("--key", required=True, help="Path to private key PEM file")
|
|
131
|
+
|
|
132
|
+
p_new = sub.add_parser("new", help="Scaffold a new plugin directory")
|
|
133
|
+
p_new.add_argument("id", help="Plugin ID (e.g. my-plugin)")
|
|
134
|
+
p_new.add_argument("--name", help="Human-readable plugin name")
|
|
135
|
+
p_new.add_argument("--dir", help="Target directory (defaults to plugin ID)")
|
|
136
|
+
|
|
137
|
+
p_pack = sub.add_parser("pack", help="Validate and pack a plugin ZIP")
|
|
138
|
+
p_pack.add_argument("dir", nargs="?", default=".", help="Plugin directory to pack")
|
|
139
|
+
p_pack.add_argument("--output", "-o", help="Output ZIP path")
|
|
140
|
+
p_pack.add_argument("--key", help="Sign with this private key PEM file")
|
|
141
|
+
|
|
142
|
+
p_validate = sub.add_parser("validate", help="Validate a plugin directory")
|
|
143
|
+
p_validate.add_argument("dir", nargs="?", default=".", help="Plugin directory to validate")
|
|
144
|
+
|
|
145
|
+
args = parser.parse_args(argv)
|
|
146
|
+
handlers = {
|
|
147
|
+
"keygen": _cmd_keygen,
|
|
148
|
+
"sign": _cmd_sign,
|
|
149
|
+
"new": _cmd_new,
|
|
150
|
+
"pack": _cmd_pack,
|
|
151
|
+
"validate": _cmd_validate,
|
|
152
|
+
}
|
|
153
|
+
return handlers[args.command](args)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
sys.exit(main())
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Plugin manifest models for Nutria plugins.
|
|
3
|
+
|
|
4
|
+
These models are the canonical schema for plugin.json and are shared between
|
|
5
|
+
the SDK (for plugin authors) and the ChatBotNutralia runtime (for validation
|
|
6
|
+
at install time).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from pathlib import Path, PurePosixPath
|
|
15
|
+
from typing import List, Optional
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, Field, field_validator
|
|
18
|
+
|
|
19
|
+
_SEMVER_RE = re.compile(
|
|
20
|
+
r"^(0|[1-9]\d*)\."
|
|
21
|
+
r"(0|[1-9]\d*)\."
|
|
22
|
+
r"(0|[1-9]\d*)"
|
|
23
|
+
r"(?:-[0-9A-Za-z.-]+)?"
|
|
24
|
+
r"(?:\+[0-9A-Za-z.-]+)?$"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _validate_relative_path(value: str) -> str:
|
|
29
|
+
path = PurePosixPath(value)
|
|
30
|
+
if path.is_absolute():
|
|
31
|
+
raise ValueError("plugin component paths must be relative")
|
|
32
|
+
if any(part in ("", ".", "..") for part in path.parts):
|
|
33
|
+
raise ValueError("plugin component paths cannot contain empty, '.' or '..' segments")
|
|
34
|
+
return path.as_posix()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PluginRuntimeType(str, Enum):
|
|
38
|
+
"""Runtime modes Nutria can use to expose plugin tools."""
|
|
39
|
+
|
|
40
|
+
REMOTE_MCP = "remote_mcp"
|
|
41
|
+
DECLARATIVE_API = "declarative_api"
|
|
42
|
+
OPENAPI_BRIDGE = "openapi_bridge"
|
|
43
|
+
SOAP_BRIDGE = "soap_bridge"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PluginScope(str, Enum):
|
|
47
|
+
"""Scope where a plugin is installed."""
|
|
48
|
+
|
|
49
|
+
PLATFORM = "platform"
|
|
50
|
+
STORE = "store"
|
|
51
|
+
PERSONA = "persona"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PluginCompatibility(BaseModel):
|
|
55
|
+
"""Compatibility gates evaluated during install."""
|
|
56
|
+
|
|
57
|
+
min_nutria_version: Optional[str] = None
|
|
58
|
+
max_nutria_version: Optional[str] = None
|
|
59
|
+
|
|
60
|
+
@field_validator("min_nutria_version", "max_nutria_version")
|
|
61
|
+
@classmethod
|
|
62
|
+
def _validate_semver(cls, value: Optional[str]) -> Optional[str]:
|
|
63
|
+
if value is None:
|
|
64
|
+
return value
|
|
65
|
+
if not _SEMVER_RE.match(value):
|
|
66
|
+
raise ValueError("compatibility versions must use semantic versioning")
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class PluginPaths(BaseModel):
|
|
71
|
+
"""Relative paths used inside the plugin ZIP."""
|
|
72
|
+
|
|
73
|
+
connections_dir: str = "connections"
|
|
74
|
+
skills_dir: str = "skills"
|
|
75
|
+
context_docs_dir: str = "context_docs"
|
|
76
|
+
settings_schema: str = "settings.schema.json"
|
|
77
|
+
hooks_file: str = "hooks/hooks.json"
|
|
78
|
+
specs_dir: str = "specs"
|
|
79
|
+
assets_dir: str = "assets"
|
|
80
|
+
|
|
81
|
+
@field_validator(
|
|
82
|
+
"connections_dir",
|
|
83
|
+
"skills_dir",
|
|
84
|
+
"context_docs_dir",
|
|
85
|
+
"settings_schema",
|
|
86
|
+
"hooks_file",
|
|
87
|
+
"specs_dir",
|
|
88
|
+
"assets_dir",
|
|
89
|
+
)
|
|
90
|
+
@classmethod
|
|
91
|
+
def _validate_paths(cls, value: str) -> str:
|
|
92
|
+
return _validate_relative_path(value)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class PluginManifest(BaseModel):
|
|
96
|
+
"""Manifest stored in plugin.json — the single source of truth for plugin metadata."""
|
|
97
|
+
|
|
98
|
+
schema_version: str = Field(default="1.0", pattern=r"^1\.0$")
|
|
99
|
+
id: str = Field(..., pattern=r"^[a-z][a-z0-9\-]*$", max_length=64)
|
|
100
|
+
name: str = Field(..., min_length=1, max_length=128)
|
|
101
|
+
version: str = Field(..., min_length=5, max_length=64)
|
|
102
|
+
description: str = Field(..., min_length=1, max_length=1024)
|
|
103
|
+
author: str = Field(..., min_length=1, max_length=128)
|
|
104
|
+
runtime_types: List[PluginRuntimeType] = Field(default_factory=list, min_length=1)
|
|
105
|
+
default_scope: PluginScope = PluginScope.STORE
|
|
106
|
+
compatibility: PluginCompatibility = Field(default_factory=PluginCompatibility)
|
|
107
|
+
paths: PluginPaths = Field(default_factory=PluginPaths)
|
|
108
|
+
required_secrets: List[str] = Field(default_factory=list)
|
|
109
|
+
remote_endpoints: List[str] = Field(default_factory=list)
|
|
110
|
+
capabilities: List[str] = Field(default_factory=list)
|
|
111
|
+
tags: List[str] = Field(default_factory=list)
|
|
112
|
+
homepage: Optional[str] = None
|
|
113
|
+
license: Optional[str] = None
|
|
114
|
+
signature: Optional[str] = None # hex-encoded ECDSA-P256 DER signature
|
|
115
|
+
|
|
116
|
+
model_config = {"extra": "forbid"}
|
|
117
|
+
|
|
118
|
+
@field_validator("version")
|
|
119
|
+
@classmethod
|
|
120
|
+
def _validate_version(cls, value: str) -> str:
|
|
121
|
+
if not _SEMVER_RE.match(value):
|
|
122
|
+
raise ValueError("plugin version must use semantic versioning")
|
|
123
|
+
return value
|
|
124
|
+
|
|
125
|
+
@field_validator("required_secrets", "capabilities", "tags")
|
|
126
|
+
@classmethod
|
|
127
|
+
def _dedupe_string_lists(cls, value: List[str]) -> List[str]:
|
|
128
|
+
cleaned: List[str] = []
|
|
129
|
+
for item in value:
|
|
130
|
+
item = item.strip()
|
|
131
|
+
if item and item not in cleaned:
|
|
132
|
+
cleaned.append(item)
|
|
133
|
+
return cleaned
|
|
134
|
+
|
|
135
|
+
@field_validator("remote_endpoints")
|
|
136
|
+
@classmethod
|
|
137
|
+
def _validate_remote_endpoints(cls, value: List[str]) -> List[str]:
|
|
138
|
+
import ipaddress
|
|
139
|
+
from urllib.parse import urlparse
|
|
140
|
+
|
|
141
|
+
endpoints: List[str] = []
|
|
142
|
+
for item in value:
|
|
143
|
+
parsed = urlparse(item)
|
|
144
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
145
|
+
raise ValueError("remote_endpoints must be absolute http/https URLs")
|
|
146
|
+
# Block SSRF targets: localhost, loopback, private/link-local/reserved IP ranges.
|
|
147
|
+
hostname = parsed.hostname or ""
|
|
148
|
+
if hostname.lower() in ("localhost", "localhost.localdomain", ""):
|
|
149
|
+
raise ValueError(
|
|
150
|
+
f"remote_endpoint {item!r} targets a private/internal address"
|
|
151
|
+
)
|
|
152
|
+
try:
|
|
153
|
+
ip = ipaddress.ip_address(hostname)
|
|
154
|
+
except ValueError:
|
|
155
|
+
ip = None # hostname (not IP literal) — cannot statically determine
|
|
156
|
+
|
|
157
|
+
if ip is not None and (
|
|
158
|
+
ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_reserved
|
|
159
|
+
):
|
|
160
|
+
raise ValueError(
|
|
161
|
+
f"remote_endpoint {item!r} targets a private/internal address"
|
|
162
|
+
)
|
|
163
|
+
if item not in endpoints:
|
|
164
|
+
endpoints.append(item)
|
|
165
|
+
return endpoints
|
|
166
|
+
|
|
167
|
+
@classmethod
|
|
168
|
+
def from_json_bytes(cls, raw: bytes) -> "PluginManifest":
|
|
169
|
+
"""Parse a plugin manifest from raw JSON bytes."""
|
|
170
|
+
return cls.model_validate(json.loads(raw.decode("utf-8")))
|
|
171
|
+
|
|
172
|
+
@classmethod
|
|
173
|
+
def from_file(cls, path: Path) -> "PluginManifest":
|
|
174
|
+
"""Load a manifest from plugin.json on disk."""
|
|
175
|
+
return cls.from_json_bytes(path.read_bytes())
|
|
176
|
+
|
|
177
|
+
def to_file(self, path: Path) -> None:
|
|
178
|
+
"""Write this manifest to a plugin.json file."""
|
|
179
|
+
path.write_text(
|
|
180
|
+
json.dumps(self.model_dump(mode="json", exclude_none=True), indent=2) + "\n",
|
|
181
|
+
encoding="utf-8",
|
|
182
|
+
)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Plugin scaffold, validation, and packaging helpers.
|
|
3
|
+
|
|
4
|
+
nutria-plugin new — scaffold a new plugin directory
|
|
5
|
+
nutria-plugin pack — validate and zip a plugin directory
|
|
6
|
+
nutria-plugin validate — validate without packing
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
import zipfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from .bundle import ALLOWED_EXTENSIONS, BLOCKED_PATTERNS, validate_zip
|
|
18
|
+
from .manifest import PluginManifest, PluginPaths, PluginRuntimeType
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PackagingError(Exception):
|
|
22
|
+
"""Raised when plugin packaging fails validation."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
SCAFFOLD_TEMPLATE = {
|
|
26
|
+
"plugin.json": lambda plugin_id, name: json.dumps(
|
|
27
|
+
{
|
|
28
|
+
"schema_version": "1.0",
|
|
29
|
+
"id": plugin_id,
|
|
30
|
+
"name": name,
|
|
31
|
+
"version": "0.1.0",
|
|
32
|
+
"description": f"{name} plugin for Nutria",
|
|
33
|
+
"author": "Your Name",
|
|
34
|
+
"runtime_types": ["declarative_api"],
|
|
35
|
+
"default_scope": "store",
|
|
36
|
+
"required_secrets": [],
|
|
37
|
+
"remote_endpoints": [],
|
|
38
|
+
"capabilities": [],
|
|
39
|
+
"tags": [],
|
|
40
|
+
},
|
|
41
|
+
indent=2,
|
|
42
|
+
)
|
|
43
|
+
+ "\n",
|
|
44
|
+
"README.md": lambda plugin_id, name: f"# {name}\n\nNutria plugin for {name}.\n",
|
|
45
|
+
"hooks/hooks.json": lambda *_: json.dumps({"hooks": []}, indent=2) + "\n",
|
|
46
|
+
"settings.schema.json": lambda *_: json.dumps(
|
|
47
|
+
{
|
|
48
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
49
|
+
"type": "object",
|
|
50
|
+
"properties": {},
|
|
51
|
+
"required": [],
|
|
52
|
+
},
|
|
53
|
+
indent=2,
|
|
54
|
+
)
|
|
55
|
+
+ "\n",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# Directories to create with no required placeholder files.
|
|
59
|
+
SCAFFOLD_DIRS = ["connections", "skills", "context_docs", "specs", "assets"]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def scaffold_plugin(
|
|
63
|
+
target_dir: Path,
|
|
64
|
+
plugin_id: str,
|
|
65
|
+
name: Optional[str] = None,
|
|
66
|
+
*,
|
|
67
|
+
overwrite: bool = False,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Create a new plugin directory with starter files.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
PackagingError: if target_dir already exists and overwrite is False.
|
|
73
|
+
"""
|
|
74
|
+
if target_dir.exists() and not overwrite:
|
|
75
|
+
raise PackagingError(
|
|
76
|
+
f"directory {target_dir} already exists; use overwrite=True to replace"
|
|
77
|
+
)
|
|
78
|
+
name = name or plugin_id
|
|
79
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
for rel, content_fn in SCAFFOLD_TEMPLATE.items():
|
|
81
|
+
dest = target_dir / rel
|
|
82
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
dest.write_text(content_fn(plugin_id, name), encoding="utf-8")
|
|
84
|
+
for d in SCAFFOLD_DIRS:
|
|
85
|
+
(target_dir / d).mkdir(exist_ok=True)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _collect_plugin_files(plugin_dir: Path) -> list[Path]:
|
|
89
|
+
"""Recursively collect files to include in the plugin ZIP."""
|
|
90
|
+
files: list[Path] = []
|
|
91
|
+
for path in sorted(plugin_dir.rglob("*")):
|
|
92
|
+
if path.is_dir():
|
|
93
|
+
continue
|
|
94
|
+
rel = path.relative_to(plugin_dir)
|
|
95
|
+
# skip hidden files/dirs
|
|
96
|
+
if any(part.startswith(".") for part in rel.parts):
|
|
97
|
+
continue
|
|
98
|
+
# reject symlinks — they could point outside the plugin directory
|
|
99
|
+
if path.is_symlink():
|
|
100
|
+
raise PackagingError(
|
|
101
|
+
f"symlinks are not allowed in plugin bundles: {rel}"
|
|
102
|
+
)
|
|
103
|
+
suffix = path.suffix.lower()
|
|
104
|
+
if suffix and suffix not in ALLOWED_EXTENSIONS:
|
|
105
|
+
raise PackagingError(
|
|
106
|
+
f"file type {suffix!r} not allowed in plugin bundle: {rel}"
|
|
107
|
+
)
|
|
108
|
+
files.append(path)
|
|
109
|
+
return files
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def pack_plugin(
|
|
113
|
+
plugin_dir: Path,
|
|
114
|
+
output_path: Optional[Path] = None,
|
|
115
|
+
*,
|
|
116
|
+
sign: bool = False,
|
|
117
|
+
private_key_pem: Optional[str] = None,
|
|
118
|
+
) -> Path:
|
|
119
|
+
"""Validate a plugin directory and produce a ZIP bundle.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
plugin_dir: Path to the plugin directory containing plugin.json.
|
|
123
|
+
output_path: Where to write the ZIP. Defaults to {plugin_id}-{version}.zip in the CWD.
|
|
124
|
+
sign: If True, sign the manifest before packing (requires private_key_pem).
|
|
125
|
+
private_key_pem: PEM-encoded EC private key for signing.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Path to the created ZIP file.
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
PackagingError: if validation fails.
|
|
132
|
+
"""
|
|
133
|
+
manifest_path = plugin_dir / "plugin.json"
|
|
134
|
+
if not manifest_path.exists():
|
|
135
|
+
raise PackagingError(f"plugin.json not found in {plugin_dir}")
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
manifest = PluginManifest.from_file(manifest_path)
|
|
139
|
+
except Exception as exc:
|
|
140
|
+
raise PackagingError(f"invalid plugin.json: {exc}") from exc
|
|
141
|
+
|
|
142
|
+
if sign:
|
|
143
|
+
if not private_key_pem:
|
|
144
|
+
raise PackagingError("private_key_pem is required when sign=True")
|
|
145
|
+
from .signing import sign_manifest
|
|
146
|
+
|
|
147
|
+
raw_dict = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
148
|
+
raw_dict["signature"] = sign_manifest(raw_dict, private_key_pem)
|
|
149
|
+
manifest_path.write_text(json.dumps(raw_dict, indent=2) + "\n", encoding="utf-8")
|
|
150
|
+
manifest = PluginManifest.from_file(manifest_path)
|
|
151
|
+
|
|
152
|
+
files = _collect_plugin_files(plugin_dir)
|
|
153
|
+
|
|
154
|
+
buf = io.BytesIO()
|
|
155
|
+
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
156
|
+
for file_path in files:
|
|
157
|
+
arcname = file_path.relative_to(plugin_dir).as_posix()
|
|
158
|
+
zf.write(file_path, arcname=arcname)
|
|
159
|
+
|
|
160
|
+
data = buf.getvalue()
|
|
161
|
+
errors = validate_zip(data)
|
|
162
|
+
if errors:
|
|
163
|
+
raise PackagingError("bundle failed validation after packing: " + "; ".join(errors))
|
|
164
|
+
|
|
165
|
+
if output_path is None:
|
|
166
|
+
output_path = Path(f"{manifest.id}-{manifest.version}.zip")
|
|
167
|
+
output_path.write_bytes(data)
|
|
168
|
+
return output_path
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def validate_plugin_dir(plugin_dir: Path) -> list[str]:
|
|
172
|
+
"""Validate a plugin directory without packing it.
|
|
173
|
+
|
|
174
|
+
Returns a list of errors (empty = valid).
|
|
175
|
+
"""
|
|
176
|
+
errors: list[str] = []
|
|
177
|
+
manifest_path = plugin_dir / "plugin.json"
|
|
178
|
+
if not manifest_path.exists():
|
|
179
|
+
errors.append("plugin.json not found")
|
|
180
|
+
return errors
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
PluginManifest.from_file(manifest_path)
|
|
184
|
+
except Exception as exc:
|
|
185
|
+
errors.append(f"invalid plugin.json: {exc}")
|
|
186
|
+
|
|
187
|
+
for path in plugin_dir.rglob("*"):
|
|
188
|
+
if path.is_dir():
|
|
189
|
+
continue
|
|
190
|
+
rel = path.relative_to(plugin_dir)
|
|
191
|
+
# Hidden files/dirs (e.g. .gitignore, .git/) are skipped by pack_plugin too;
|
|
192
|
+
# flag them only if they would cause a problem, not just because they exist.
|
|
193
|
+
if any(part.startswith(".") for part in rel.parts):
|
|
194
|
+
continue
|
|
195
|
+
if path.is_symlink():
|
|
196
|
+
errors.append(f"symlinks not allowed: {rel}")
|
|
197
|
+
continue
|
|
198
|
+
suffix = path.suffix.lower()
|
|
199
|
+
if suffix and suffix not in ALLOWED_EXTENSIONS:
|
|
200
|
+
errors.append(f"file type {suffix!r} not allowed: {rel}")
|
|
201
|
+
|
|
202
|
+
return errors
|
nutria_plugin/signing.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ECDSA-P256 plugin manifest signing and verification.
|
|
3
|
+
|
|
4
|
+
Trusted public keys are configured via the NUTRIA_PLUGIN_TRUSTED_KEYS
|
|
5
|
+
environment variable — a JSON array of PEM-encoded EC public keys:
|
|
6
|
+
|
|
7
|
+
NUTRIA_PLUGIN_TRUSTED_KEYS='["-----BEGIN PUBLIC KEY-----\\n..."]'
|
|
8
|
+
|
|
9
|
+
CLI:
|
|
10
|
+
nutria-plugin keygen # generate a key pair
|
|
11
|
+
nutria-plugin sign plugin.json # sign a manifest in-place
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
from cryptography.exceptions import InvalidSignature
|
|
23
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
24
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SignatureStatus(str, Enum):
|
|
28
|
+
"""Result of a manifest signature verification attempt."""
|
|
29
|
+
|
|
30
|
+
VERIFIED = "verified" # Signature present and valid against a trusted key
|
|
31
|
+
MISSING = "missing" # No signature field, or NUTRIA_PLUGIN_TRUSTED_KEYS not set
|
|
32
|
+
INVALID = "invalid" # Signature present but verification failed
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def generate_keypair() -> tuple[str, str]:
|
|
36
|
+
"""Generate an ECDSA P-256 key pair.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Tuple of (private_key_pem, public_key_pem) as strings.
|
|
40
|
+
"""
|
|
41
|
+
private_key = ec.generate_private_key(ec.SECP256R1())
|
|
42
|
+
private_pem = private_key.private_bytes(
|
|
43
|
+
encoding=serialization.Encoding.PEM,
|
|
44
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
45
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
46
|
+
).decode("utf-8")
|
|
47
|
+
public_pem = private_key.public_key().public_bytes(
|
|
48
|
+
encoding=serialization.Encoding.PEM,
|
|
49
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
50
|
+
).decode("utf-8")
|
|
51
|
+
return private_pem, public_pem
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _canonical_payload(manifest_dict: dict) -> bytes:
|
|
55
|
+
"""Return a deterministic JSON representation of the manifest for signing.
|
|
56
|
+
|
|
57
|
+
The 'signature' field is excluded so the payload is stable whether the
|
|
58
|
+
manifest is pre- or post-signing.
|
|
59
|
+
"""
|
|
60
|
+
data = {k: v for k, v in manifest_dict.items() if k != "signature"}
|
|
61
|
+
return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode(
|
|
62
|
+
"utf-8"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def sign_manifest(manifest_dict: dict, private_key_pem: str) -> str:
|
|
67
|
+
"""Sign a plugin manifest dict and return the hex-encoded DER signature.
|
|
68
|
+
|
|
69
|
+
The returned value should be stored in manifest_dict['signature'].
|
|
70
|
+
"""
|
|
71
|
+
private_key = serialization.load_pem_private_key(
|
|
72
|
+
private_key_pem.encode("utf-8"), password=None
|
|
73
|
+
)
|
|
74
|
+
if not isinstance(private_key, ec.EllipticCurvePrivateKey):
|
|
75
|
+
raise TypeError("Private key must be an EC key")
|
|
76
|
+
return private_key.sign(_canonical_payload(manifest_dict), ec.ECDSA(hashes.SHA256())).hex()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _load_trusted_public_keys() -> list[ec.EllipticCurvePublicKey]:
|
|
80
|
+
"""Load trusted EC public keys from NUTRIA_PLUGIN_TRUSTED_KEYS.
|
|
81
|
+
|
|
82
|
+
Returns an empty list when the env var is not set.
|
|
83
|
+
Raises ValueError when the env var is set but malformed — so a typo in
|
|
84
|
+
production fails loudly instead of silently accepting all plugins.
|
|
85
|
+
"""
|
|
86
|
+
raw = os.environ.get("NUTRIA_PLUGIN_TRUSTED_KEYS", "")
|
|
87
|
+
if not raw:
|
|
88
|
+
return []
|
|
89
|
+
try:
|
|
90
|
+
pem_list: list[str] = json.loads(raw)
|
|
91
|
+
if not isinstance(pem_list, list):
|
|
92
|
+
raise ValueError("NUTRIA_PLUGIN_TRUSTED_KEYS must be a JSON array")
|
|
93
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
94
|
+
raise ValueError(f"NUTRIA_PLUGIN_TRUSTED_KEYS is set but invalid: {exc}") from exc
|
|
95
|
+
|
|
96
|
+
keys: list[ec.EllipticCurvePublicKey] = []
|
|
97
|
+
for pem in pem_list:
|
|
98
|
+
try:
|
|
99
|
+
key = serialization.load_pem_public_key(pem.encode("utf-8"))
|
|
100
|
+
if isinstance(key, ec.EllipticCurvePublicKey):
|
|
101
|
+
keys.append(key)
|
|
102
|
+
except Exception:
|
|
103
|
+
pass # skip individual bad keys; caller gets fewer trusted keys
|
|
104
|
+
return keys
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def verify_manifest(manifest_dict: dict) -> SignatureStatus:
|
|
108
|
+
"""Verify the ECDSA signature embedded in a plugin manifest dict.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
SignatureStatus.VERIFIED – valid against at least one trusted key
|
|
112
|
+
SignatureStatus.MISSING – no signature field, or keys not configured
|
|
113
|
+
SignatureStatus.INVALID – signature present but verification failed
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
ValueError: if NUTRIA_PLUGIN_TRUSTED_KEYS is set but malformed.
|
|
117
|
+
"""
|
|
118
|
+
sig_hex: Optional[str] = manifest_dict.get("signature")
|
|
119
|
+
if not sig_hex:
|
|
120
|
+
return SignatureStatus.MISSING
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
sig_der = bytes.fromhex(sig_hex)
|
|
124
|
+
except ValueError:
|
|
125
|
+
return SignatureStatus.INVALID
|
|
126
|
+
|
|
127
|
+
payload = _canonical_payload(manifest_dict)
|
|
128
|
+
trusted_keys = _load_trusted_public_keys() # may raise ValueError
|
|
129
|
+
|
|
130
|
+
if not trusted_keys:
|
|
131
|
+
return SignatureStatus.MISSING
|
|
132
|
+
|
|
133
|
+
for key in trusted_keys:
|
|
134
|
+
try:
|
|
135
|
+
key.verify(sig_der, payload, ec.ECDSA(hashes.SHA256()))
|
|
136
|
+
return SignatureStatus.VERIFIED
|
|
137
|
+
except InvalidSignature:
|
|
138
|
+
continue
|
|
139
|
+
except Exception:
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
return SignatureStatus.INVALID
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nutria-plugin
|
|
3
|
+
Version: 0.0.1a0
|
|
4
|
+
Summary: SDK for building, validating, signing, and packaging Nutria plugins
|
|
5
|
+
Project-URL: Homepage, https://github.com/AlRos14/nutria-plugin-sdk
|
|
6
|
+
Project-URL: Repository, https://github.com/AlRos14/nutria-plugin-sdk
|
|
7
|
+
Project-URL: Changelog, https://github.com/AlRos14/nutria-plugin-sdk/blob/main/CHANGELOG.md
|
|
8
|
+
Author-email: Nutria <dev@nutria.ai>
|
|
9
|
+
License: MIT
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Requires-Dist: cryptography>=41.0
|
|
12
|
+
Requires-Dist: lxml>=4.9
|
|
13
|
+
Requires-Dist: pydantic>=2.0
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
17
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# nutria-plugin SDK
|
|
21
|
+
|
|
22
|
+
SDK for building, validating, signing, and packaging Nutria plugins.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install nutria-plugin
|
|
28
|
+
# or with uv
|
|
29
|
+
uv add nutria-plugin
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quickstart
|
|
33
|
+
|
|
34
|
+
### 1. Scaffold a new plugin
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
nutria-plugin new my-workspace-plugin --name "My Workspace Plugin"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
This creates:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
my-workspace-plugin/
|
|
44
|
+
plugin.json # manifest — edit this
|
|
45
|
+
README.md
|
|
46
|
+
connections/ # one JSON file per connection
|
|
47
|
+
skills/ # SKILL.md files
|
|
48
|
+
context_docs/ # Markdown docs injected into persona prompts
|
|
49
|
+
specs/ # OpenAPI/WSDL specs
|
|
50
|
+
hooks/hooks.json # declarative hooks
|
|
51
|
+
settings.schema.json # config schema shown in admin UI
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 2. Edit plugin.json
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"schema_version": "1.0",
|
|
59
|
+
"id": "my-workspace-plugin",
|
|
60
|
+
"name": "My Workspace Plugin",
|
|
61
|
+
"version": "0.1.0",
|
|
62
|
+
"description": "Connects Nutria to My Workspace tool",
|
|
63
|
+
"author": "Your Name",
|
|
64
|
+
"runtime_types": ["declarative_api"],
|
|
65
|
+
"required_secrets": ["API_KEY"],
|
|
66
|
+
"remote_endpoints": ["https://api.myworkspace.com"]
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### 3. Validate
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
nutria-plugin validate .
|
|
74
|
+
# OK
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### 4. Pack
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
nutria-plugin pack . --output my-workspace-plugin-0.1.0.zip
|
|
81
|
+
# Packed: my-workspace-plugin-0.1.0.zip
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 5. Sign (optional)
|
|
85
|
+
|
|
86
|
+
Generate a key pair once:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
nutria-plugin keygen --out my-signing-key
|
|
90
|
+
# Private key: my-signing-key.pem
|
|
91
|
+
# Public key: my-signing-key.pub.pem
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Sign before packing:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
nutria-plugin sign plugin.json --key my-signing-key.pem
|
|
98
|
+
nutria-plugin pack . --output my-workspace-plugin-0.1.0.zip
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Or sign during pack:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
nutria-plugin pack . --key my-signing-key.pem --output my-workspace-plugin-0.1.0.zip
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Configure the Nutria instance to trust your public key:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
export NUTRIA_PLUGIN_TRUSTED_KEYS='["-----BEGIN PUBLIC KEY-----\n..."]'
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Python API
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from nutria_plugin import (
|
|
117
|
+
PluginManifest,
|
|
118
|
+
load_plugin_bundle,
|
|
119
|
+
extract_plugin_bundle,
|
|
120
|
+
validate_zip,
|
|
121
|
+
scaffold_plugin,
|
|
122
|
+
pack_plugin,
|
|
123
|
+
validate_plugin_dir,
|
|
124
|
+
generate_keypair,
|
|
125
|
+
sign_manifest,
|
|
126
|
+
verify_manifest,
|
|
127
|
+
SignatureStatus,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Parse a manifest
|
|
131
|
+
manifest = PluginManifest.from_file(Path("plugin.json"))
|
|
132
|
+
|
|
133
|
+
# Load from ZIP bytes
|
|
134
|
+
manifest = load_plugin_bundle(zip_bytes)
|
|
135
|
+
|
|
136
|
+
# Extract to disk
|
|
137
|
+
manifest = extract_plugin_bundle(zip_bytes, target_dir)
|
|
138
|
+
|
|
139
|
+
# Sign and verify
|
|
140
|
+
private_pem, public_pem = generate_keypair()
|
|
141
|
+
sig = sign_manifest(manifest.model_dump(), private_pem)
|
|
142
|
+
status = verify_manifest(manifest.model_dump())
|
|
143
|
+
assert status == SignatureStatus.VERIFIED
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Plugin ZIP format
|
|
147
|
+
|
|
148
|
+
| Path | Description |
|
|
149
|
+
|------|-------------|
|
|
150
|
+
| `plugin.json` | Manifest (required) |
|
|
151
|
+
| `README.md` | Human-readable description |
|
|
152
|
+
| `connections/*.json` | Connection definitions |
|
|
153
|
+
| `skills/<name>/SKILL.md` | Skill instructions |
|
|
154
|
+
| `context_docs/*.md` | Docs injected into persona prompts |
|
|
155
|
+
| `specs/openapi.json` | OpenAPI spec (for `openapi_bridge` runtime) |
|
|
156
|
+
| `specs/service.wsdl` | WSDL spec (for `soap_bridge` runtime) |
|
|
157
|
+
| `hooks/hooks.json` | Declarative hooks |
|
|
158
|
+
| `settings.schema.json` | JSON Schema for configuration |
|
|
159
|
+
| `assets/icon.png` | Plugin icon |
|
|
160
|
+
|
|
161
|
+
### Security rules
|
|
162
|
+
|
|
163
|
+
- No executable files (`.py`, `.js`, `.sh`, etc.) allowed in the ZIP.
|
|
164
|
+
- No hidden files or directories.
|
|
165
|
+
- No absolute paths or path traversal in ZIP entries.
|
|
166
|
+
- Maximum bundle size: 20 MB.
|
|
167
|
+
- Secrets are **never** stored in the ZIP — they are configured after install.
|
|
168
|
+
|
|
169
|
+
## Runtime types
|
|
170
|
+
|
|
171
|
+
| Value | Description |
|
|
172
|
+
|-------|-------------|
|
|
173
|
+
| `remote_mcp` | Plugin connects to a separately deployed MCP server |
|
|
174
|
+
| `declarative_api` | Plugin uses declarative connection JSON files (no server needed) |
|
|
175
|
+
| `openapi_bridge` | Nutria auto-generates tools from an OpenAPI/Swagger spec |
|
|
176
|
+
| `soap_bridge` | Nutria auto-generates tools from a WSDL/SOAP spec |
|
|
177
|
+
|
|
178
|
+
## License
|
|
179
|
+
|
|
180
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
nutria_plugin/__init__.py,sha256=bdQuSU54aLpoDOsXcgsR3ZfdpspSEfynOsIpqbHhbIY,1239
|
|
2
|
+
nutria_plugin/bundle.py,sha256=-2tYptl-rC-xlzr3rOMmd6UtS3JWryASjeaxdYNhh8s,6019
|
|
3
|
+
nutria_plugin/cli.py,sha256=yrQbdRAuNIUg8exxQgPPhnWAex3oo1rl9A63iuebKNs,5223
|
|
4
|
+
nutria_plugin/manifest.py,sha256=4sGMxDQxVfFMGYddY2KiPlh3pedwN_Bu44GlXyaR3H0,6339
|
|
5
|
+
nutria_plugin/packaging.py,sha256=g2j-5BDIgFhCgobJtlflZk0v_XXgPlAqDBj2r2MBVwo,6678
|
|
6
|
+
nutria_plugin/signing.py,sha256=5xUqTVSz-0txPI6fvOEXtPhLSSTcCSkgUN9fZELP47c,5055
|
|
7
|
+
nutria_plugin-0.0.1a0.dist-info/METADATA,sha256=Hi4EAjbMqsO9tbZer0shKWgyjQYavO52xVSsTC9bdyc,4574
|
|
8
|
+
nutria_plugin-0.0.1a0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
nutria_plugin-0.0.1a0.dist-info/entry_points.txt,sha256=k1YvyaEBQOjCTCjDlKaw2t_f_HL38tkQMPNbFxnn7mg,57
|
|
10
|
+
nutria_plugin-0.0.1a0.dist-info/RECORD,,
|