Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,29 @@ labeled PRs.

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

## [1.1.2] — 2026-05-20

### Added

- `@route` decorator stamps canonical `_dryade_route_meta` on the
wrapped callable, in addition to the legacy `spec` attribute.
- `collect_routes(plugin)` and `build_router(plugin)` helpers in
`dryade_plugins_sdk.plugin`: walk a plugin instance and produce a
FastAPI `APIRouter` from every decorated method. Lazy-imports
FastAPI. Plugins that pre-build `self.router` get it back unchanged
(no double-mount).
- `dryade plugin package` 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.

### Fixed

- (host, via DryadeAI/Dryade #962) The plugin loader's `isinstance`
load gate now accepts both the host's `PluginProtocol` ABC and the
public SDK's `Plugin` Protocol. Plugins authored with
`dryade plugin new` no longer need to inherit from `core.ee.*`.

## [1.0.0] — TBD

Initial public release. The SDK is the pure-contract package that Dryade
Expand Down
5 changes: 5 additions & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ dryade plugin package ./my_plugin --output ./dist
Output: `<name>-<version>.dryadepkg` — a gzipped tar archive containing:

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

Author key material is **never** bundled (T-339-04-03 mitigation).
Expand Down
38 changes: 38 additions & 0 deletions docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,44 @@ When you submit to the marketplace, the marketplace **re-signs** with its
own dual Ed25519 + ML-DSA-65 keys before publishing in the signed allowlist.
Authors never see the production signing material.

## SBOM — embedded CycloneDX

Every `.dryadepkg` produced by `dryade plugin package` carries an
embedded CycloneDX 1.5 SBOM at `sbom.cdx.json`. The packager tries
`cyclonedx-py` first (full SBOM with components and dependencies). If
`cyclonedx-py` is not on `PATH` the SBOM falls back to a minimal shim
with the component metadata only — the shim is flagged via
`metadata.properties[dryade:sbom-source = "minimal-shim"]` so consumers
can distinguish the two at audit time. The manifest also carries an
`sbom` field with the same source string so downstream provenance
checks can read it without opening the SBOM file.

## Routes — `@route` decorator

Plugins can expose HTTP endpoints by decorating methods with `@route`:

```python
from dryade_plugins_sdk import build_router, route

class MyPlugin:
name = "my_plugin"
# ...

@route(path="/status", method="GET")
def status(self):
return {"ok": True}

def register(self, registry):
registry.register(build_router(self))
```

`build_router(plugin)` walks the plugin via `collect_routes(plugin)`,
binds every decorated method onto a fresh FastAPI `APIRouter`, and
returns it. Plugins that already construct their own `self.router`
get that router back unchanged — no double-mount. FastAPI is imported
lazily, so plugins without routes don't pull FastAPI into the SDK's
import path.

## Hashing — contract version 4

Every plugin source file gets hashed with **both SHA-256 and SHA3-256**.
Expand Down
38 changes: 30 additions & 8 deletions examples/with_tool/plugin.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
"""Dryade plugin example — exposes one Tool the host LLM can call.

The ``@tool`` decorator stamps a ``ToolSchema`` on the function. At load time
the host's FakeRegistry (or production registry) discriminates by attribute
shape and routes the callable to the tool bus.
"""Dryade plugin example — one ``@tool`` for the host LLM + one ``@route``
HTTP endpoint that calls it.

The ``@tool`` decorator stamps a ``ToolSchema`` on the function. The
``@route`` decorator stamps ``_dryade_route_meta`` so
``build_router(plugin)`` can produce a FastAPI ``APIRouter`` from the
plugin's decorated methods. The host (or the plugin's own ``register``)
mounts the router under the plugin's namespace.
"""

from __future__ import annotations
Expand All @@ -11,7 +14,13 @@
from datetime import datetime, timezone
from typing import Any

from dryade_plugins_sdk import HealthCheck, ManageableComponent, tool
from dryade_plugins_sdk import (
HealthCheck,
ManageableComponent,
build_router,
route,
tool,
)


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


class WithToolPlugin:
"""Plugin that exposes one tool."""
"""Plugin that exposes one tool and one route."""

name = "with_tool"
version = "0.1.0"
description = "Dryade plugin example — registers one tool the host LLM can call."
description = "Dryade plugin example — registers one tool and one HTTP route."
core_version_constraint = ">=1.0.0,<2.0.0"

@route(path="/now", method="GET", auth_required=True)
def now(self) -> dict[str, str]:
"""HTTP endpoint that returns the current UTC time as JSON.

Mounted under the plugin's namespace by ``build_router(self)``.
"""
return {"utc": get_current_time()}

def register(self, registry: Any) -> None:
# Register the tool with the host's tool bus.
registry.register(get_current_time)
# Build a FastAPI router from every @route-decorated method on
# this plugin and register it with the host. The host knows how
# to mount an APIRouter under the plugin's prefix.
registry.register(build_router(self))

def startup(self, **kwargs: Any) -> None:
pass
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "dryade-plugins-sdk"
version = "1.1.1"
version = "1.1.2"
description = "Dryade plugin SDK — Protocol contracts and author tooling primitives"
readme = "README.md"
requires-python = ">=3.11"
Expand Down Expand Up @@ -59,6 +59,7 @@ cli = [
"typer>=0.12",
"jinja2>=3.1",
"cryptography>=42",
"cyclonedx-bom>=4.0",
]
docs = [
"mkdocs-material>=9.5",
Expand Down
12 changes: 10 additions & 2 deletions src/dryade_plugins_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@

from __future__ import annotations

from dryade_plugins_sdk.plugin import Plugin, HealthCheck, ManageableComponent
from dryade_plugins_sdk.plugin import (
Plugin,
HealthCheck,
ManageableComponent,
collect_routes,
build_router,
)
from dryade_plugins_sdk.agent import (
Agent,
AgentFramework,
Expand Down Expand Up @@ -46,7 +52,7 @@
verify_plugin_hash,
)

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

__all__ = [
Expand All @@ -65,6 +71,8 @@
"tool",
"Route",
"route",
"collect_routes",
"build_router",
"Config",
# Supporting protocols (339-03b)
"KV",
Expand Down
30 changes: 30 additions & 0 deletions src/dryade_plugins_sdk/cli/pkg.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,25 +116,55 @@ def build_dryadepkg(plugin_dir: Path, output_dir: Path) -> Path:
output_dir.mkdir(parents=True, exist_ok=True)
pkg_path = output_dir / f"{name}-{version}.dryadepkg"

# Build the CycloneDX SBOM for the plugin. Full path uses cyclonedx-py;
# fallback is a minimal shim flagged via metadata.properties so consumers
# can distinguish the two at audit time.
from dryade_plugins_sdk.cli.sbom import build_sbom

sbom_doc = build_sbom(plugin_dir, name, version)
sbom_source = "minimal-shim"
for prop in sbom_doc.get("metadata", {}).get("properties", []) or []:
if prop.get("name") == "dryade:sbom-source":
sbom_source = prop.get("value") or sbom_source
break
# Flag the SBOM source on the manifest itself so the host (and
# downstream provenance checks) can read it from dryade.json without
# unpacking sbom.cdx.json.
manifest["sbom"] = sbom_source

# Write the re-signed manifest to a temp file so we can tar-add it under
# the canonical arcname while leaving the on-disk dryade.json untouched.
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as tmp:
tmp.write(json.dumps(manifest, sort_keys=True, indent=2))
tmp_manifest_path = Path(tmp.name)

# Write the SBOM to a sibling temp file we can add under the canonical
# arcname `sbom.cdx.json` without leaving it in the plugin tree.
with tempfile.NamedTemporaryFile(
"w", suffix=".cdx.json", delete=False, encoding="utf-8"
) as tmp_sbom:
tmp_sbom.write(json.dumps(sbom_doc, sort_keys=True, indent=2))
tmp_sbom_path = Path(tmp_sbom.name)

try:
with tarfile.open(pkg_path, "w:gz") as tf:
tf.add(tmp_manifest_path, arcname="dryade.json")
tf.add(tmp_sbom_path, arcname="sbom.cdx.json")
for f in sorted(plugin_dir.rglob("*")):
if not f.is_file():
continue
rel = f.relative_to(plugin_dir)
if rel.name == "dryade.json":
# The signed copy was already added under arcname=dryade.json.
continue
if rel.name == "sbom.cdx.json":
# Skip any pre-existing SBOM; we just added our freshly
# generated one under the canonical arcname.
continue
if _should_include(rel):
tf.add(f, arcname=str(rel))
finally:
tmp_manifest_path.unlink(missing_ok=True)
tmp_sbom_path.unlink(missing_ok=True)

return pkg_path
131 changes: 131 additions & 0 deletions src/dryade_plugins_sdk/cli/sbom.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""CycloneDX SBOM generation for ``.dryadepkg`` bundles.

The packager calls :func:`build_sbom` to produce a CycloneDX 1.5 JSON
document describing the plugin and its declared dependencies. The SBOM
is embedded in the ``.dryadepkg`` tarball as ``sbom.cdx.json`` next to
``dryade.json``.

Two production paths:

1. **Full SBOM**: ``cyclonedx-py`` shells out from the active venv and
produces a complete CycloneDX 1.5 document from the plugin's
``pyproject.toml`` + installed deps. Falls back to (2) on any
non-zero exit.
2. **Minimal shim**: a hand-built skeleton with just the component
metadata. The shim is flagged in the SBOM's ``metadata.properties``
so consumers can tell a shim from a full SBOM.
"""

from __future__ import annotations

import json
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Any


def _minimal_shim(name: str, version: str) -> dict[str, Any]:
"""Return a CycloneDX 1.5-shaped doc with only the component metadata."""
return {
"bomFormat": "CycloneDX",
"specVersion": "1.5",
"version": 1,
"metadata": {
"component": {
"type": "library",
"name": name,
"version": version,
"bom-ref": f"{name}@{version}",
},
"properties": [
{"name": "dryade:sbom-source", "value": "minimal-shim"},
],
},
"components": [],
}


def _run_cyclonedx(pyproject_path: Path, output_path: Path) -> bool:
"""Run cyclonedx-py against a plugin's pyproject.toml.

Returns True on success (output file populated with a valid SBOM),
False on any failure (caller should fall back to the minimal shim).
"""
cli = shutil.which("cyclonedx-py")
if cli is None:
return False
cmd = [
cli,
"poetry", # subcommand is moot for pure pyproject parse below;
# but most cyclonedx-py builds accept `requirements` or `environment`.
]
# cyclonedx-py 4+ has subcommands: poetry / requirements / environment.
# Use `requirements -` to read from stdin would need a requirements
# file; the cleanest path is `environment` against the active venv.
cmd = [cli, "environment", "-o", str(output_path), "--output-format", "JSON"]
try:
proc = subprocess.run(
cmd,
cwd=pyproject_path.parent,
capture_output=True,
text=True,
timeout=60,
)
except (subprocess.TimeoutExpired, OSError):
return False
if proc.returncode != 0:
return False
if not output_path.exists() or output_path.stat().st_size == 0:
return False
# Sanity-check structure.
try:
doc = json.loads(output_path.read_text())
except (OSError, json.JSONDecodeError):
return False
if doc.get("bomFormat") != "CycloneDX":
return False
return True


def build_sbom(plugin_dir: Path, name: str, version: str) -> dict[str, Any]:
"""Produce a CycloneDX SBOM dict for the plugin at ``plugin_dir``.

Tries the full ``cyclonedx-py`` path first; falls back to a minimal
shim with a ``dryade:sbom-source = minimal-shim`` property so
consumers can distinguish full vs shim at audit time.

Returns a dict ready to ``json.dumps``.
"""
pyproject = plugin_dir / "pyproject.toml"
if pyproject.exists():
with tempfile.NamedTemporaryFile(
"w", suffix=".cdx.json", delete=False, encoding="utf-8"
) as tmp:
tmp_path = Path(tmp.name)
try:
if _run_cyclonedx(pyproject, tmp_path):
try:
doc = json.loads(tmp_path.read_text())
except (OSError, json.JSONDecodeError):
doc = None
if isinstance(doc, dict) and doc.get("bomFormat") == "CycloneDX":
# Tag the doc so consumers can tell the source apart.
meta = doc.setdefault("metadata", {})
props = meta.setdefault("properties", [])
if not any(
p.get("name") == "dryade:sbom-source" for p in props
):
props.append(
{"name": "dryade:sbom-source", "value": "cyclonedx-py"}
)
return doc
finally:
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass

# Fallback path: minimal shim.
return _minimal_shim(name, version)
Loading
Loading