nutria-plugin 0.2.1__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/CHANGELOG.md +6 -0
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/PKG-INFO +5 -4
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/README.md +3 -2
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/pyproject.toml +1 -1
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/src/nutria_plugin/__init__.py +11 -1
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/src/nutria_plugin/capabilities.py +105 -0
- nutria_plugin-0.2.2/tests/test_capability_contracts.py +97 -0
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/uv.lock +1 -1
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/.github/workflows/publish.yml +0 -0
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/.gitignore +0 -0
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/docs/admin-extensions.md +0 -0
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/docs/admin-flows.md +0 -0
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/docs/cli.md +0 -0
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/docs/connection-types.md +0 -0
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/docs/index.md +0 -0
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/docs/manifest.md +0 -0
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/docs/python-api.md +0 -0
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/docs/quickstart.md +0 -0
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/docs/reviewable-actions.md +0 -0
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/docs/security.md +0 -0
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/docs/skill-format.md +0 -0
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/examples/my-first-plugin/README.md +0 -0
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/examples/my-first-plugin/hooks/hooks.json +0 -0
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/examples/my-first-plugin/plugin.json +0 -0
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/examples/my-first-plugin/settings.schema.json +0 -0
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/src/nutria_plugin/bundle.py +0 -0
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/src/nutria_plugin/cli.py +0 -0
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/src/nutria_plugin/manifest.py +0 -0
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/src/nutria_plugin/packaging.py +0 -0
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/src/nutria_plugin/signing.py +0 -0
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/tests/test_bundle.py +0 -0
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/tests/test_cli.py +0 -0
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/tests/test_manifest.py +0 -0
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/tests/test_packaging.py +0 -0
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/tests/test_reviewable_actions.py +0 -0
- {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/tests/test_signing.py +0 -0
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.2
|
|
4
|
+
|
|
5
|
+
- Add backward-compatible schema-2.1 capability exposure and safe non-callability metadata.
|
|
6
|
+
- Add prepared-action, idempotency, and completion receipt contracts.
|
|
7
|
+
- Require model-selectable external writes to declare all execution safety contracts.
|
|
8
|
+
|
|
3
9
|
## 0.2.1
|
|
4
10
|
|
|
5
11
|
- Add manifest schema `2.1` with typed world providers, custom resource types,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: nutria-plugin
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: SDK for building, validating, signing, and packaging Nutria plugins
|
|
5
5
|
Project-URL: Homepage, https://github.com/AlRos14/nutria-plugin-sdk
|
|
6
6
|
Project-URL: Repository, https://github.com/AlRos14/nutria-plugin-sdk
|
|
@@ -21,8 +21,9 @@ Description-Content-Type: text/markdown
|
|
|
21
21
|
|
|
22
22
|
SDK for building, validating, signing, and packaging Nutria plugins.
|
|
23
23
|
|
|
24
|
-
Release `0.2.
|
|
25
|
-
|
|
24
|
+
Release `0.2.2` adds capability exposure, prepared-action, idempotency, and
|
|
25
|
+
completion contracts to manifest schema `2.1` while continuing to accept existing
|
|
26
|
+
schema `2.0` and `2.1` manifests. ChatBotNutralia owns reviewable drafts, revisions, and approval; a
|
|
26
27
|
plugin only reads channel state and delivers the exact approved snapshot.
|
|
27
28
|
|
|
28
29
|
This release also supports **declarative admin extensions**, allowing plugins
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
SDK for building, validating, signing, and packaging Nutria plugins.
|
|
4
4
|
|
|
5
|
-
Release `0.2.
|
|
6
|
-
|
|
5
|
+
Release `0.2.2` adds capability exposure, prepared-action, idempotency, and
|
|
6
|
+
completion contracts to manifest schema `2.1` while continuing to accept existing
|
|
7
|
+
schema `2.0` and `2.1` manifests. ChatBotNutralia owns reviewable drafts, revisions, and approval; a
|
|
7
8
|
plugin only reads channel state and delivers the exact approved snapshot.
|
|
8
9
|
|
|
9
10
|
This release also supports **declarative admin extensions**, allowing plugins
|
|
@@ -15,14 +15,19 @@ Signing:
|
|
|
15
15
|
generate_keypair, sign_manifest, verify_manifest, SignatureStatus
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
|
-
__version__ = "0.2.
|
|
18
|
+
__version__ = "0.2.2"
|
|
19
19
|
|
|
20
20
|
from .capabilities import (
|
|
21
21
|
CapabilityDescriptor,
|
|
22
22
|
CapabilityEffect,
|
|
23
|
+
CapabilityExposure,
|
|
23
24
|
CapabilityInputBinding,
|
|
24
25
|
CapabilityOutputBinding,
|
|
25
26
|
CapabilityRequirement,
|
|
27
|
+
CompletionDescriptor,
|
|
28
|
+
IdempotencyDescriptor,
|
|
29
|
+
NonCallableReason,
|
|
30
|
+
PreparedActionDescriptor,
|
|
26
31
|
ResourceBinding,
|
|
27
32
|
ResourceType,
|
|
28
33
|
ResourceTypeDescriptor,
|
|
@@ -72,9 +77,14 @@ __all__ = [
|
|
|
72
77
|
"PreparationToolContract",
|
|
73
78
|
"CapabilityDescriptor",
|
|
74
79
|
"CapabilityEffect",
|
|
80
|
+
"CapabilityExposure",
|
|
75
81
|
"CapabilityInputBinding",
|
|
76
82
|
"CapabilityOutputBinding",
|
|
77
83
|
"CapabilityRequirement",
|
|
84
|
+
"CompletionDescriptor",
|
|
85
|
+
"IdempotencyDescriptor",
|
|
86
|
+
"NonCallableReason",
|
|
87
|
+
"PreparedActionDescriptor",
|
|
78
88
|
"ResourceBinding",
|
|
79
89
|
"ResourceType",
|
|
80
90
|
"ResourceTypeDescriptor",
|
|
@@ -18,6 +18,69 @@ class CapabilityEffect(str, Enum):
|
|
|
18
18
|
EXTERNAL_WRITE = "external_write"
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
class CapabilityExposure(str, Enum):
|
|
22
|
+
"""Who may invoke a graph-visible capability."""
|
|
23
|
+
|
|
24
|
+
MODEL = "model"
|
|
25
|
+
HOST = "host"
|
|
26
|
+
ADMIN = "admin"
|
|
27
|
+
DEPRECATED = "deprecated"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NonCallableReason(BaseModel):
|
|
31
|
+
"""Safe explanation for a graph-visible capability that the model cannot load."""
|
|
32
|
+
|
|
33
|
+
code: str = Field(..., pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
|
|
34
|
+
safe_summary: str = Field(..., min_length=1, max_length=500)
|
|
35
|
+
|
|
36
|
+
model_config = {"extra": "forbid"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PreparedActionDescriptor(BaseModel):
|
|
40
|
+
"""Exact-preview contract used by the host prepared-action boundary."""
|
|
41
|
+
|
|
42
|
+
preview_argument: str = Field(..., pattern=r"^[a-zA-Z][a-zA-Z0-9_-]{0,127}$")
|
|
43
|
+
preview_value: Any
|
|
44
|
+
execute_value: Any
|
|
45
|
+
adapter: str = Field(..., pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
|
|
46
|
+
ttl_seconds: int = Field(..., ge=60, le=86_400)
|
|
47
|
+
merge_previews: bool
|
|
48
|
+
guard_mode: str = Field(..., pattern=r"^(always|pending_only)$")
|
|
49
|
+
argument_default: Any
|
|
50
|
+
|
|
51
|
+
model_config = {"extra": "forbid"}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class IdempotencyDescriptor(BaseModel):
|
|
55
|
+
"""Execution idempotency contract for a capability."""
|
|
56
|
+
|
|
57
|
+
argument_name: str = Field(..., pattern=r"^[a-zA-Z][a-zA-Z0-9_-]{0,127}$")
|
|
58
|
+
required_for_execution: bool
|
|
59
|
+
|
|
60
|
+
model_config = {"extra": "forbid"}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class CompletionDescriptor(BaseModel):
|
|
64
|
+
"""Authoritative receipt kinds required before a capability is complete."""
|
|
65
|
+
|
|
66
|
+
receipts: list[str] = Field(..., min_length=1)
|
|
67
|
+
|
|
68
|
+
model_config = {"extra": "forbid"}
|
|
69
|
+
|
|
70
|
+
@field_validator("receipts")
|
|
71
|
+
@classmethod
|
|
72
|
+
def _validate_receipts(cls, value: list[str]) -> list[str]:
|
|
73
|
+
cleaned = list(dict.fromkeys(item.strip() for item in value if item.strip()))
|
|
74
|
+
if not cleaned:
|
|
75
|
+
raise ValueError("completion receipts must not be empty")
|
|
76
|
+
for receipt in cleaned:
|
|
77
|
+
import re
|
|
78
|
+
|
|
79
|
+
if not re.fullmatch(r"^[a-z][a-z0-9_.-]{0,127}$", receipt):
|
|
80
|
+
raise ValueError("completion receipts must be stable lowercase identifiers")
|
|
81
|
+
return cleaned
|
|
82
|
+
|
|
83
|
+
|
|
21
84
|
class ResourceType(str, Enum):
|
|
22
85
|
TENANT = "tenant"
|
|
23
86
|
CLIENT = "client"
|
|
@@ -213,6 +276,11 @@ class CapabilityDescriptor(BaseModel):
|
|
|
213
276
|
inputs: list[CapabilityInputBinding] = Field(default_factory=list)
|
|
214
277
|
requirements: CapabilityRequirement = Field(default_factory=CapabilityRequirement)
|
|
215
278
|
model_callable: bool = True
|
|
279
|
+
exposure: CapabilityExposure | None = None
|
|
280
|
+
non_callable_reason: NonCallableReason | None = None
|
|
281
|
+
prepared_action: PreparedActionDescriptor | None = None
|
|
282
|
+
idempotency: IdempotencyDescriptor | None = None
|
|
283
|
+
completion: CompletionDescriptor | None = None
|
|
216
284
|
reviewable_action_id: str | None = Field(
|
|
217
285
|
default=None, pattern=r"^[a-z][a-z0-9-]{0,63}$"
|
|
218
286
|
)
|
|
@@ -221,6 +289,24 @@ class CapabilityDescriptor(BaseModel):
|
|
|
221
289
|
|
|
222
290
|
@model_validator(mode="after")
|
|
223
291
|
def _validate_contract(self) -> "CapabilityDescriptor":
|
|
292
|
+
if self.exposure is None:
|
|
293
|
+
self.exposure = (
|
|
294
|
+
CapabilityExposure.MODEL if self.model_callable else CapabilityExposure.HOST
|
|
295
|
+
)
|
|
296
|
+
if not self.model_callable and self.non_callable_reason is None:
|
|
297
|
+
self.non_callable_reason = NonCallableReason(
|
|
298
|
+
code="legacy_host_only",
|
|
299
|
+
safe_summary="This capability is available only through the trusted host.",
|
|
300
|
+
)
|
|
301
|
+
if self.exposure == CapabilityExposure.MODEL and not self.model_callable:
|
|
302
|
+
raise ValueError("exposure=model requires model_callable=true")
|
|
303
|
+
if self.exposure != CapabilityExposure.MODEL:
|
|
304
|
+
if self.model_callable:
|
|
305
|
+
raise ValueError("non-model exposure requires model_callable=false")
|
|
306
|
+
if self.non_callable_reason is None:
|
|
307
|
+
raise ValueError("non-model exposure requires non_callable_reason")
|
|
308
|
+
elif self.non_callable_reason is not None:
|
|
309
|
+
raise ValueError("model exposure must not declare non_callable_reason")
|
|
224
310
|
if self.connection_id and self.requirements.connection_id not in (None, self.connection_id):
|
|
225
311
|
raise ValueError("capability connection_id conflicts with its requirement")
|
|
226
312
|
if (
|
|
@@ -235,6 +321,25 @@ class CapabilityDescriptor(BaseModel):
|
|
|
235
321
|
output_names = [item.output_name for item in self.produces]
|
|
236
322
|
if len(output_names) != len(set(output_names)):
|
|
237
323
|
raise ValueError("capability outputs must not repeat output names")
|
|
324
|
+
if self.effect == CapabilityEffect.EXTERNAL_WRITE and self.model_callable:
|
|
325
|
+
missing = [
|
|
326
|
+
name
|
|
327
|
+
for name, value in (
|
|
328
|
+
("prepared_action", self.prepared_action),
|
|
329
|
+
("idempotency", self.idempotency),
|
|
330
|
+
("completion", self.completion),
|
|
331
|
+
)
|
|
332
|
+
if value is None
|
|
333
|
+
]
|
|
334
|
+
if missing:
|
|
335
|
+
raise ValueError(
|
|
336
|
+
"model-selectable external writes require " + ", ".join(missing)
|
|
337
|
+
)
|
|
338
|
+
assert self.idempotency is not None
|
|
339
|
+
if not self.idempotency.required_for_execution:
|
|
340
|
+
raise ValueError(
|
|
341
|
+
"model-selectable external writes require execution idempotency"
|
|
342
|
+
)
|
|
238
343
|
return self
|
|
239
344
|
|
|
240
345
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Capability exposure and effect-safety contracts introduced in SDK 0.2.2."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from pydantic import ValidationError
|
|
7
|
+
|
|
8
|
+
from nutria_plugin import CapabilityDescriptor, CapabilityExposure
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _capability(**overrides):
|
|
12
|
+
payload = {
|
|
13
|
+
"id": "shipping.create",
|
|
14
|
+
"title": "Create shipment",
|
|
15
|
+
"description": "Create one shipment after an exact preview.",
|
|
16
|
+
"effect": "external_write",
|
|
17
|
+
"tool": "create_shipment",
|
|
18
|
+
}
|
|
19
|
+
payload.update(overrides)
|
|
20
|
+
return payload
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _safety_contracts():
|
|
24
|
+
return {
|
|
25
|
+
"prepared_action": {
|
|
26
|
+
"preview_argument": "preview_only",
|
|
27
|
+
"preview_value": True,
|
|
28
|
+
"execute_value": False,
|
|
29
|
+
"adapter": "exact_preview",
|
|
30
|
+
"ttl_seconds": 3600,
|
|
31
|
+
"merge_previews": True,
|
|
32
|
+
"guard_mode": "pending_only",
|
|
33
|
+
"argument_default": True,
|
|
34
|
+
},
|
|
35
|
+
"idempotency": {
|
|
36
|
+
"argument_name": "idempotency_key",
|
|
37
|
+
"required_for_execution": True,
|
|
38
|
+
},
|
|
39
|
+
"completion": {"receipts": ["shipping.shipment.created"]},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_legacy_model_capability_gets_explicit_model_exposure():
|
|
44
|
+
descriptor = CapabilityDescriptor.model_validate(
|
|
45
|
+
_capability(effect="read", model_callable=True)
|
|
46
|
+
)
|
|
47
|
+
assert descriptor.exposure == CapabilityExposure.MODEL
|
|
48
|
+
assert descriptor.non_callable_reason is None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_legacy_non_callable_capability_gets_safe_host_projection():
|
|
52
|
+
descriptor = CapabilityDescriptor.model_validate(
|
|
53
|
+
_capability(model_callable=False)
|
|
54
|
+
)
|
|
55
|
+
assert descriptor.exposure == CapabilityExposure.HOST
|
|
56
|
+
assert descriptor.non_callable_reason.code == "legacy_host_only"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.mark.parametrize("exposure", ["host", "admin", "deprecated"])
|
|
60
|
+
def test_non_model_exposure_requires_reason(exposure):
|
|
61
|
+
with pytest.raises(ValidationError, match="non_callable_reason"):
|
|
62
|
+
CapabilityDescriptor.model_validate(
|
|
63
|
+
_capability(exposure=exposure, model_callable=False)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_model_exposure_requires_model_callable():
|
|
68
|
+
with pytest.raises(ValidationError, match="model_callable"):
|
|
69
|
+
CapabilityDescriptor.model_validate(
|
|
70
|
+
_capability(exposure="model", model_callable=False)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_model_selectable_external_write_requires_all_safety_contracts():
|
|
75
|
+
with pytest.raises(ValidationError, match="prepared_action"):
|
|
76
|
+
CapabilityDescriptor.model_validate(_capability())
|
|
77
|
+
|
|
78
|
+
descriptor = CapabilityDescriptor.model_validate(
|
|
79
|
+
_capability(**_safety_contracts())
|
|
80
|
+
)
|
|
81
|
+
assert descriptor.prepared_action.adapter == "exact_preview"
|
|
82
|
+
assert descriptor.idempotency.required_for_execution is True
|
|
83
|
+
assert descriptor.completion.receipts == ["shipping.shipment.created"]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_host_external_write_remains_valid_without_prepared_contract():
|
|
87
|
+
descriptor = CapabilityDescriptor.model_validate(
|
|
88
|
+
_capability(
|
|
89
|
+
exposure="host",
|
|
90
|
+
model_callable=False,
|
|
91
|
+
non_callable_reason={
|
|
92
|
+
"code": "prepared_execution_only",
|
|
93
|
+
"safe_summary": "Execution is restricted to the prepared-action host.",
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
assert descriptor.exposure == CapabilityExposure.HOST
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|