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.
@@ -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
+ ]
@@ -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
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nutria-plugin = nutria_plugin.cli:main