Skip to content

Commit 7674379

Browse files
DryadeCoreDryadeDammerzone
authored
feat(sdk): 1.1.2 — @route wires into FastAPI, CycloneDX SBOM in .dryadepkg (#6)
Two author-surface gaps from sdk_proof e2e: Gap #4@route was a no-op decorator. The decorator now stamps a canonical _dryade_route_meta dict on the wrapped callable (keeping the legacy `spec` attribute for backwards compatibility), and the SDK ships two new helpers in dryade_plugins_sdk.plugin: - collect_routes(plugin) — walks dir(plugin), returns [(meta, callable), ...] ordered by __qualname__. - build_router(plugin) -> fastapi.APIRouter — produces a FastAPI router from every decorated method; lazy-imports FastAPI so plugins without routes don't pull it in; returns plugin.router unchanged when present so the legacy pattern keeps working. with_tool example now uses @route + build_router as the canonical reference pattern. Gap #6 — dryade plugin package emitted no SBOM. The packager now embeds a CycloneDX 1.5 SBOM as sbom.cdx.json in every .dryadepkg. Full SBOM via cyclonedx-py when available, minimal shim otherwise. The manifest's `sbom` field records the source (minimal-shim | cyclonedx-py) so consumers can read the source without unpacking the SBOM file. Tests: - tests/test_route_decorator.py: 8 cases covering decorator metadata, collect_routes ordering + legacy-spec fallback, build_router output via FastAPI TestClient, plugin.router passthrough, empty-router path. - tests/test_package_sbom.py: asserts every .dryadepkg contains a valid CycloneDX 1.5 SBOM and the manifest carries the sbom-source flag. Co-authored-by: Dryade <contact@dryade.ai> Co-authored-by: Dammerzone <dammerzone@users.noreply.github.com>
1 parent d321a17 commit 7674379

13 files changed

Lines changed: 686 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,29 @@ labeled PRs.
1313

1414
_release-drafter manages this section on every PR merge — do not edit by hand._
1515

16+
## [1.1.2] — 2026-05-20
17+
18+
### Added
19+
20+
- `@route` decorator stamps canonical `_dryade_route_meta` on the
21+
wrapped callable, in addition to the legacy `spec` attribute.
22+
- `collect_routes(plugin)` and `build_router(plugin)` helpers in
23+
`dryade_plugins_sdk.plugin`: walk a plugin instance and produce a
24+
FastAPI `APIRouter` from every decorated method. Lazy-imports
25+
FastAPI. Plugins that pre-build `self.router` get it back unchanged
26+
(no double-mount).
27+
- `dryade plugin package` embeds a CycloneDX 1.5 SBOM as
28+
`sbom.cdx.json` in every `.dryadepkg`. Full SBOM via `cyclonedx-py`
29+
when available, minimal shim otherwise. The manifest's `sbom` field
30+
records the source.
31+
32+
### Fixed
33+
34+
- (host, via DryadeAI/Dryade #962) The plugin loader's `isinstance`
35+
load gate now accepts both the host's `PluginProtocol` ABC and the
36+
public SDK's `Plugin` Protocol. Plugins authored with
37+
`dryade plugin new` no longer need to inherit from `core.ee.*`.
38+
1639
## [1.0.0] — TBD
1740

1841
Initial public release. The SDK is the pure-contract package that Dryade

docs/cli-reference.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ dryade plugin package ./my_plugin --output ./dist
8888
Output: `<name>-<version>.dryadepkg` — a gzipped tar archive containing:
8989

9090
- `dryade.json` with signed hashes and Ed25519 signature.
91+
- `sbom.cdx.json` — CycloneDX 1.5 SBOM (full when `cyclonedx-py` is on
92+
`PATH`, otherwise a minimal shim flagged via
93+
`metadata.properties[dryade:sbom-source]`). The manifest also carries
94+
`sbom: "cyclonedx-py" | "minimal-shim"` so consumers can read the
95+
source without unpacking the SBOM.
9196
- The plugin's `__init__.py`, `plugin.py`, and any other `.py` files.
9297

9398
Author key material is **never** bundled (T-339-04-03 mitigation).

docs/concepts.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,44 @@ When you submit to the marketplace, the marketplace **re-signs** with its
7373
own dual Ed25519 + ML-DSA-65 keys before publishing in the signed allowlist.
7474
Authors never see the production signing material.
7575

76+
## SBOM — embedded CycloneDX
77+
78+
Every `.dryadepkg` produced by `dryade plugin package` carries an
79+
embedded CycloneDX 1.5 SBOM at `sbom.cdx.json`. The packager tries
80+
`cyclonedx-py` first (full SBOM with components and dependencies). If
81+
`cyclonedx-py` is not on `PATH` the SBOM falls back to a minimal shim
82+
with the component metadata only — the shim is flagged via
83+
`metadata.properties[dryade:sbom-source = "minimal-shim"]` so consumers
84+
can distinguish the two at audit time. The manifest also carries an
85+
`sbom` field with the same source string so downstream provenance
86+
checks can read it without opening the SBOM file.
87+
88+
## Routes — `@route` decorator
89+
90+
Plugins can expose HTTP endpoints by decorating methods with `@route`:
91+
92+
```python
93+
from dryade_plugins_sdk import build_router, route
94+
95+
class MyPlugin:
96+
name = "my_plugin"
97+
# ...
98+
99+
@route(path="/status", method="GET")
100+
def status(self):
101+
return {"ok": True}
102+
103+
def register(self, registry):
104+
registry.register(build_router(self))
105+
```
106+
107+
`build_router(plugin)` walks the plugin via `collect_routes(plugin)`,
108+
binds every decorated method onto a fresh FastAPI `APIRouter`, and
109+
returns it. Plugins that already construct their own `self.router`
110+
get that router back unchanged — no double-mount. FastAPI is imported
111+
lazily, so plugins without routes don't pull FastAPI into the SDK's
112+
import path.
113+
76114
## Hashing — contract version 4
77115

78116
Every plugin source file gets hashed with **both SHA-256 and SHA3-256**.

examples/with_tool/plugin.py

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
"""Dryade plugin example — exposes one Tool the host LLM can call.
2-
3-
The ``@tool`` decorator stamps a ``ToolSchema`` on the function. At load time
4-
the host's FakeRegistry (or production registry) discriminates by attribute
5-
shape and routes the callable to the tool bus.
1+
"""Dryade plugin example — one ``@tool`` for the host LLM + one ``@route``
2+
HTTP endpoint that calls it.
3+
4+
The ``@tool`` decorator stamps a ``ToolSchema`` on the function. The
5+
``@route`` decorator stamps ``_dryade_route_meta`` so
6+
``build_router(plugin)`` can produce a FastAPI ``APIRouter`` from the
7+
plugin's decorated methods. The host (or the plugin's own ``register``)
8+
mounts the router under the plugin's namespace.
69
"""
710

811
from __future__ import annotations
@@ -11,7 +14,13 @@
1114
from datetime import datetime, timezone
1215
from typing import Any
1316

14-
from dryade_plugins_sdk import HealthCheck, ManageableComponent, tool
17+
from dryade_plugins_sdk import (
18+
HealthCheck,
19+
ManageableComponent,
20+
build_router,
21+
route,
22+
tool,
23+
)
1524

1625

1726
@tool(
@@ -24,15 +33,28 @@ def get_current_time() -> str:
2433

2534

2635
class WithToolPlugin:
27-
"""Plugin that exposes one tool."""
36+
"""Plugin that exposes one tool and one route."""
2837

2938
name = "with_tool"
3039
version = "0.1.0"
31-
description = "Dryade plugin example — registers one tool the host LLM can call."
40+
description = "Dryade plugin example — registers one tool and one HTTP route."
3241
core_version_constraint = ">=1.0.0,<2.0.0"
3342

43+
@route(path="/now", method="GET", auth_required=True)
44+
def now(self) -> dict[str, str]:
45+
"""HTTP endpoint that returns the current UTC time as JSON.
46+
47+
Mounted under the plugin's namespace by ``build_router(self)``.
48+
"""
49+
return {"utc": get_current_time()}
50+
3451
def register(self, registry: Any) -> None:
52+
# Register the tool with the host's tool bus.
3553
registry.register(get_current_time)
54+
# Build a FastAPI router from every @route-decorated method on
55+
# this plugin and register it with the host. The host knows how
56+
# to mount an APIRouter under the plugin's prefix.
57+
registry.register(build_router(self))
3658

3759
def startup(self, **kwargs: Any) -> None:
3860
pass

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "dryade-plugins-sdk"
7-
version = "1.1.1"
7+
version = "1.1.2"
88
description = "Dryade plugin SDK — Protocol contracts and author tooling primitives"
99
readme = "README.md"
1010
requires-python = ">=3.11"
@@ -59,6 +59,7 @@ cli = [
5959
"typer>=0.12",
6060
"jinja2>=3.1",
6161
"cryptography>=42",
62+
"cyclonedx-bom>=4.0",
6263
]
6364
docs = [
6465
"mkdocs-material>=9.5",

src/dryade_plugins_sdk/__init__.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,13 @@
1414

1515
from __future__ import annotations
1616

17-
from dryade_plugins_sdk.plugin import Plugin, HealthCheck, ManageableComponent
17+
from dryade_plugins_sdk.plugin import (
18+
Plugin,
19+
HealthCheck,
20+
ManageableComponent,
21+
collect_routes,
22+
build_router,
23+
)
1824
from dryade_plugins_sdk.agent import (
1925
Agent,
2026
AgentFramework,
@@ -46,7 +52,7 @@
4652
verify_plugin_hash,
4753
)
4854

49-
__version__ = "1.0.0"
55+
__version__ = "1.1.2"
5056
__contract_version__ = 4 # SHA-256 + SHA3-256 dual hash (Rule §9)
5157

5258
__all__ = [
@@ -65,6 +71,8 @@
6571
"tool",
6672
"Route",
6773
"route",
74+
"collect_routes",
75+
"build_router",
6876
"Config",
6977
# Supporting protocols (339-03b)
7078
"KV",

src/dryade_plugins_sdk/cli/pkg.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,25 +116,55 @@ def build_dryadepkg(plugin_dir: Path, output_dir: Path) -> Path:
116116
output_dir.mkdir(parents=True, exist_ok=True)
117117
pkg_path = output_dir / f"{name}-{version}.dryadepkg"
118118

119+
# Build the CycloneDX SBOM for the plugin. Full path uses cyclonedx-py;
120+
# fallback is a minimal shim flagged via metadata.properties so consumers
121+
# can distinguish the two at audit time.
122+
from dryade_plugins_sdk.cli.sbom import build_sbom
123+
124+
sbom_doc = build_sbom(plugin_dir, name, version)
125+
sbom_source = "minimal-shim"
126+
for prop in sbom_doc.get("metadata", {}).get("properties", []) or []:
127+
if prop.get("name") == "dryade:sbom-source":
128+
sbom_source = prop.get("value") or sbom_source
129+
break
130+
# Flag the SBOM source on the manifest itself so the host (and
131+
# downstream provenance checks) can read it from dryade.json without
132+
# unpacking sbom.cdx.json.
133+
manifest["sbom"] = sbom_source
134+
119135
# Write the re-signed manifest to a temp file so we can tar-add it under
120136
# the canonical arcname while leaving the on-disk dryade.json untouched.
121137
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as tmp:
122138
tmp.write(json.dumps(manifest, sort_keys=True, indent=2))
123139
tmp_manifest_path = Path(tmp.name)
124140

141+
# Write the SBOM to a sibling temp file we can add under the canonical
142+
# arcname `sbom.cdx.json` without leaving it in the plugin tree.
143+
with tempfile.NamedTemporaryFile(
144+
"w", suffix=".cdx.json", delete=False, encoding="utf-8"
145+
) as tmp_sbom:
146+
tmp_sbom.write(json.dumps(sbom_doc, sort_keys=True, indent=2))
147+
tmp_sbom_path = Path(tmp_sbom.name)
148+
125149
try:
126150
with tarfile.open(pkg_path, "w:gz") as tf:
127151
tf.add(tmp_manifest_path, arcname="dryade.json")
152+
tf.add(tmp_sbom_path, arcname="sbom.cdx.json")
128153
for f in sorted(plugin_dir.rglob("*")):
129154
if not f.is_file():
130155
continue
131156
rel = f.relative_to(plugin_dir)
132157
if rel.name == "dryade.json":
133158
# The signed copy was already added under arcname=dryade.json.
134159
continue
160+
if rel.name == "sbom.cdx.json":
161+
# Skip any pre-existing SBOM; we just added our freshly
162+
# generated one under the canonical arcname.
163+
continue
135164
if _should_include(rel):
136165
tf.add(f, arcname=str(rel))
137166
finally:
138167
tmp_manifest_path.unlink(missing_ok=True)
168+
tmp_sbom_path.unlink(missing_ok=True)
139169

140170
return pkg_path

src/dryade_plugins_sdk/cli/sbom.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""CycloneDX SBOM generation for ``.dryadepkg`` bundles.
2+
3+
The packager calls :func:`build_sbom` to produce a CycloneDX 1.5 JSON
4+
document describing the plugin and its declared dependencies. The SBOM
5+
is embedded in the ``.dryadepkg`` tarball as ``sbom.cdx.json`` next to
6+
``dryade.json``.
7+
8+
Two production paths:
9+
10+
1. **Full SBOM**: ``cyclonedx-py`` shells out from the active venv and
11+
produces a complete CycloneDX 1.5 document from the plugin's
12+
``pyproject.toml`` + installed deps. Falls back to (2) on any
13+
non-zero exit.
14+
2. **Minimal shim**: a hand-built skeleton with just the component
15+
metadata. The shim is flagged in the SBOM's ``metadata.properties``
16+
so consumers can tell a shim from a full SBOM.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import json
22+
import shutil
23+
import subprocess
24+
import tempfile
25+
from pathlib import Path
26+
from typing import Any
27+
28+
29+
def _minimal_shim(name: str, version: str) -> dict[str, Any]:
30+
"""Return a CycloneDX 1.5-shaped doc with only the component metadata."""
31+
return {
32+
"bomFormat": "CycloneDX",
33+
"specVersion": "1.5",
34+
"version": 1,
35+
"metadata": {
36+
"component": {
37+
"type": "library",
38+
"name": name,
39+
"version": version,
40+
"bom-ref": f"{name}@{version}",
41+
},
42+
"properties": [
43+
{"name": "dryade:sbom-source", "value": "minimal-shim"},
44+
],
45+
},
46+
"components": [],
47+
}
48+
49+
50+
def _run_cyclonedx(pyproject_path: Path, output_path: Path) -> bool:
51+
"""Run cyclonedx-py against a plugin's pyproject.toml.
52+
53+
Returns True on success (output file populated with a valid SBOM),
54+
False on any failure (caller should fall back to the minimal shim).
55+
"""
56+
cli = shutil.which("cyclonedx-py")
57+
if cli is None:
58+
return False
59+
cmd = [
60+
cli,
61+
"poetry", # subcommand is moot for pure pyproject parse below;
62+
# but most cyclonedx-py builds accept `requirements` or `environment`.
63+
]
64+
# cyclonedx-py 4+ has subcommands: poetry / requirements / environment.
65+
# Use `requirements -` to read from stdin would need a requirements
66+
# file; the cleanest path is `environment` against the active venv.
67+
cmd = [cli, "environment", "-o", str(output_path), "--output-format", "JSON"]
68+
try:
69+
proc = subprocess.run(
70+
cmd,
71+
cwd=pyproject_path.parent,
72+
capture_output=True,
73+
text=True,
74+
timeout=60,
75+
)
76+
except (subprocess.TimeoutExpired, OSError):
77+
return False
78+
if proc.returncode != 0:
79+
return False
80+
if not output_path.exists() or output_path.stat().st_size == 0:
81+
return False
82+
# Sanity-check structure.
83+
try:
84+
doc = json.loads(output_path.read_text())
85+
except (OSError, json.JSONDecodeError):
86+
return False
87+
if doc.get("bomFormat") != "CycloneDX":
88+
return False
89+
return True
90+
91+
92+
def build_sbom(plugin_dir: Path, name: str, version: str) -> dict[str, Any]:
93+
"""Produce a CycloneDX SBOM dict for the plugin at ``plugin_dir``.
94+
95+
Tries the full ``cyclonedx-py`` path first; falls back to a minimal
96+
shim with a ``dryade:sbom-source = minimal-shim`` property so
97+
consumers can distinguish full vs shim at audit time.
98+
99+
Returns a dict ready to ``json.dumps``.
100+
"""
101+
pyproject = plugin_dir / "pyproject.toml"
102+
if pyproject.exists():
103+
with tempfile.NamedTemporaryFile(
104+
"w", suffix=".cdx.json", delete=False, encoding="utf-8"
105+
) as tmp:
106+
tmp_path = Path(tmp.name)
107+
try:
108+
if _run_cyclonedx(pyproject, tmp_path):
109+
try:
110+
doc = json.loads(tmp_path.read_text())
111+
except (OSError, json.JSONDecodeError):
112+
doc = None
113+
if isinstance(doc, dict) and doc.get("bomFormat") == "CycloneDX":
114+
# Tag the doc so consumers can tell the source apart.
115+
meta = doc.setdefault("metadata", {})
116+
props = meta.setdefault("properties", [])
117+
if not any(
118+
p.get("name") == "dryade:sbom-source" for p in props
119+
):
120+
props.append(
121+
{"name": "dryade:sbom-source", "value": "cyclonedx-py"}
122+
)
123+
return doc
124+
finally:
125+
try:
126+
tmp_path.unlink(missing_ok=True)
127+
except OSError:
128+
pass
129+
130+
# Fallback path: minimal shim.
131+
return _minimal_shim(name, version)

0 commit comments

Comments
 (0)