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.
Files changed (36) hide show
  1. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/CHANGELOG.md +6 -0
  2. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/PKG-INFO +5 -4
  3. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/README.md +3 -2
  4. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/pyproject.toml +1 -1
  5. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/src/nutria_plugin/__init__.py +11 -1
  6. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/src/nutria_plugin/capabilities.py +105 -0
  7. nutria_plugin-0.2.2/tests/test_capability_contracts.py +97 -0
  8. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/uv.lock +1 -1
  9. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/.github/workflows/publish.yml +0 -0
  10. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/.gitignore +0 -0
  11. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/docs/admin-extensions.md +0 -0
  12. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/docs/admin-flows.md +0 -0
  13. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/docs/cli.md +0 -0
  14. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/docs/connection-types.md +0 -0
  15. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/docs/index.md +0 -0
  16. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/docs/manifest.md +0 -0
  17. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/docs/python-api.md +0 -0
  18. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/docs/quickstart.md +0 -0
  19. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/docs/reviewable-actions.md +0 -0
  20. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/docs/security.md +0 -0
  21. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/docs/skill-format.md +0 -0
  22. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/examples/my-first-plugin/README.md +0 -0
  23. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/examples/my-first-plugin/hooks/hooks.json +0 -0
  24. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/examples/my-first-plugin/plugin.json +0 -0
  25. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/examples/my-first-plugin/settings.schema.json +0 -0
  26. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/src/nutria_plugin/bundle.py +0 -0
  27. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/src/nutria_plugin/cli.py +0 -0
  28. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/src/nutria_plugin/manifest.py +0 -0
  29. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/src/nutria_plugin/packaging.py +0 -0
  30. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/src/nutria_plugin/signing.py +0 -0
  31. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/tests/test_bundle.py +0 -0
  32. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/tests/test_cli.py +0 -0
  33. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/tests/test_manifest.py +0 -0
  34. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/tests/test_packaging.py +0 -0
  35. {nutria_plugin-0.2.1 → nutria_plugin-0.2.2}/tests/test_reviewable_actions.py +0 -0
  36. {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.4
1
+ Metadata-Version: 2.5
2
2
  Name: nutria-plugin
3
- Version: 0.2.1
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.1` adds provider-backed world graph descriptors in manifest schema
25
- `2.1` while continuing to accept schema `2.0`. ChatBotNutralia owns reviewable drafts, revisions, and approval; a
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.1` adds provider-backed world graph descriptors in manifest schema
6
- `2.1` while continuing to accept schema `2.0`. ChatBotNutralia owns reviewable drafts, revisions, and approval; a
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nutria-plugin"
7
- version = "0.2.1"
7
+ version = "0.2.2"
8
8
  description = "SDK for building, validating, signing, and packaging Nutria plugins"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -15,14 +15,19 @@ Signing:
15
15
  generate_keypair, sign_manifest, verify_manifest, SignatureStatus
16
16
  """
17
17
 
18
- __version__ = "0.2.1"
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
@@ -262,7 +262,7 @@ wheels = [
262
262
 
263
263
  [[package]]
264
264
  name = "nutria-plugin"
265
- version = "0.2.1"
265
+ version = "0.2.2"
266
266
  source = { editable = "." }
267
267
  dependencies = [
268
268
  { name = "cryptography" },
File without changes
File without changes