diff --git a/.claude/skills/plugin-scaffold/SKILL.md b/.claude/skills/plugin-scaffold/SKILL.md index 98bb0aa..fd16473 100644 --- a/.claude/skills/plugin-scaffold/SKILL.md +++ b/.claude/skills/plugin-scaffold/SKILL.md @@ -30,7 +30,7 @@ If the plugin slug or type is missing, ask once. **Common to all types:** -- `plugins//plugin.json` — schema-valid manifest. Set `id`, `name`, `version: "0.1.0"`, `license: "AGPL-3.0-only"`, and `standards: ["capability-pipelines.v1", "plugin-runtime-idempotent.v1"]` by default (ask if a different license is desired). +- `plugins//plugin.json` — schema-valid manifest. Set `id`, `name`, `version: "0.1.0"`, `license: "AGPL-3.0-only"`, and `standards: ["capability-pipelines.v1", "plugin-runtime-idempotent.v1"]` by default. **`type=visualization`** — adds: - `"type": "visualization"` and `"script": "screen.js"` to manifest diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 2838fd4..c825d73 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -34,5 +34,5 @@ Thanks for the PR! A few quick reminders before you hit submit: - [ ] DCO sign-off on every commit (`Signed-off-by:` trailer) - [ ] Conventional-commit subject (`feat(scope):`, `fix(scope):`, `docs:`, `chore:`) -- [ ] `CHANGELOG.md` `[Unreleased]` section updated (skip for chore/docs) +- [ ] `CHANGELOG.md` `[Unreleased]` section updated - [ ] Documentation updated if behaviour or contracts changed diff --git a/docs/plugin-manifest.md b/docs/plugin-manifest.md index 69e9593..8e2a9c0 100644 --- a/docs/plugin-manifest.md +++ b/docs/plugin-manifest.md @@ -180,7 +180,7 @@ Supported declaration fields include: - `roles`: `owner`, `coordinator`, `provider`, `observer`, `requester`, `transformer`, `handler`, `validator`, `short-circuiter`, `contributor` - `commands`, `operations`, `requests`, `observes`, `emits`, `events`: string arrays naming public commands, provider operations, or events - `kind`: `command`, `provider-coordinator`, `event`, `diagnostic`, `privileged` -- `mode`: `active`, `optional`, `disabled` +- `mode`: `active`, `optional`, `legacy-shim`, `disabled` - `compatibility`: prefer `none` for new declarations - `ownership`: `exclusive-owner`, `multi-provider`, `observer-only`, `requester-only`, `privileged`, `diagnostic-only` - `safety`: `safe`, `privileged`, `sensitive`, `diagnostic-only` @@ -191,7 +191,7 @@ Invalid capability metadata is rejected by schema validation and ignored by runt ### `license` (string, optional but recommended) -SPDX identifier. For curated plugins, must be AGPL-3.0-or-later or AGPL-compatible (MIT, BSD-2-Clause, BSD-3-Clause, Apache-2.0). See [CONTRIBUTING.md](../CONTRIBUTING.md). +SPDX identifier. Contributions must use `AGPL-3.0-only`. See [CONTRIBUTING.md](../CONTRIBUTING.md). ## Backend routes — `setup(app, context)` diff --git a/docs/plugin-manifest.schema.json b/docs/plugin-manifest.schema.json index 48be633..efd7465 100644 --- a/docs/plugin-manifest.schema.json +++ b/docs/plugin-manifest.schema.json @@ -95,15 +95,41 @@ "type": "object" }, "nav": {}, - "screen": {}, - "script": {}, - "routes": {}, - "settings": {}, + "screen": { + "$ref": "#/$defs/pluginRelpath" + }, + "script": { + "$ref": "#/$defs/pluginRelpath" + }, + "routes": { + "$ref": "#/$defs/pluginRelpath" + }, + "settings": { + "type": "object", + "additionalProperties": false, + "properties": { + "html": { + "$ref": "#/$defs/pluginRelpath" + }, + "server_files": { + "type": "array", + "items": { + "type": "string", + "not": { + "pattern": "^/|^[a-zA-Z]:|\\\\|(^|/)\\.\\.(/|$)|//|(^|/)\\.(/|$)|^\\." + } + }, + "uniqueItems": true + } + } + }, "diagnostics": {}, "type": { "type": "string" }, - "tour": {}, + "tour": { + "$ref": "#/$defs/pluginRelpath" + }, "description": { "type": "string", "description": "Short one-sentence summary of the plugin, surfaced on the v3 Pedalboard Plugins page (clamped to ~2 lines). Optional and additive." @@ -127,6 +153,13 @@ }, "additionalProperties": true, "$defs": { + "pluginRelpath": { + "type": "string", + "minLength": 1, + "not": { + "pattern": "^/|^[a-zA-Z]:|\\\\|(^|/)\\.\\.(/|$)|//|(^|/)\\.(/|$)|^\\.|[?#]" + } + }, "domainName": { "type": "string", "minLength": 1, diff --git a/schema/plugin.schema.json b/schema/plugin.schema.json index 941fd0b..b1e6bd3 100644 --- a/schema/plugin.schema.json +++ b/schema/plugin.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/slopsmith/slopsmith/blob/main/schema/plugin.schema.json", "title": "Slopsmith plugin manifest", - "description": "Schema for plugins//plugin.json. See docs/plugin-manifest.md for prose. License enum mirrors the curated-plugin allowlist in CONTRIBUTING.md (keep them in sync; tests/test_plugin_schema.py asserts subset). Capability metadata follows capability-pipelines.v1 and is intentionally additive.", + "description": "Schema for plugins//plugin.json. See docs/plugin-manifest.md for prose. Contributions must use AGPL-3.0-only. Capability metadata follows capability-pipelines.v1 and is intentionally additive.", "type": "object", "additionalProperties": true, "required": ["id", "name"], @@ -51,23 +51,8 @@ }, "license": { "type": "string", - "description": "SPDX identifier. For curated plugins, must match CONTRIBUTING.md's allowlist.", - "enum": [ - "AGPL-3.0-only", - "AGPL-3.0-or-later", - "GPL-3.0-only", - "GPL-3.0-or-later", - "LGPL-3.0-only", - "LGPL-3.0-or-later", - "MIT", - "BSD-2-Clause", - "BSD-3-Clause", - "Apache-2.0", - "ISC", - "Unlicense", - "CC0-1.0", - "0BSD" - ] + "description": "SPDX identifier. Contributions must use AGPL-3.0-only.", + "enum": ["AGPL-3.0-only"] }, "nav": { "type": "object", @@ -80,11 +65,11 @@ "description": "Optional navigation metadata. Plugin UI declares stable ui contributions for attribution." }, "screen": { - "type": "string", + "$ref": "#/$defs/pluginRelpath", "description": "Relative path to HTML mounted at #plugin-." }, "script": { - "type": "string", + "$ref": "#/$defs/pluginRelpath", "description": "Relative path to JS loaded in global scope on page load. Wrap your code in an IIFE." }, "styles": { @@ -94,11 +79,11 @@ "description": "Plugin-root-relative path under assets/ (e.g. 'assets/plugin.css') to a compiled, preflight-off Tailwind stylesheet the frontend injects as a . Must stay under assets/ with no '..', backslash, query, or fragment. See docs/plugin-styles.md." }, "routes": { - "type": "string", + "$ref": "#/$defs/pluginRelpath", "description": "Relative path to Python module exporting setup(app, context). See docs/plugin-manifest.md." }, "tour": { - "type": "string", + "$ref": "#/$defs/pluginRelpath", "description": "Relative path to tour JSON for the in-app onboarding tour." }, "settings": { @@ -106,7 +91,7 @@ "additionalProperties": false, "properties": { "html": { - "type": "string", + "$ref": "#/$defs/pluginRelpath", "description": "Relative path to settings-panel HTML." }, "server_files": { @@ -195,6 +180,11 @@ } ], "$defs": { + "pluginRelpath": { + "type": "string", + "minLength": 1, + "not": { "pattern": "^/|^[a-zA-Z]:|\\\\|(^|/)\\.\\.(/|$)|//|(^|/)\\.(/|$)|^\\.|[?#]" } + }, "domainName": { "type": "string", "minLength": 1, diff --git a/tests/test_plugin_schema.py b/tests/test_plugin_schema.py index 74def92..5de9c18 100644 --- a/tests/test_plugin_schema.py +++ b/tests/test_plugin_schema.py @@ -7,12 +7,8 @@ Four independent guarantees: 2. Each in-tree manifest's `id` matches its parent directory name — the loader assumes this and silent drift would break plugin discovery. -3. The `license` enum in the schema is a subset of the SPDX identifiers - listed in `CONTRIBUTING.md`'s "Plugin licensing" curated allowlist. - If you edit the allowlist in `CONTRIBUTING.md`, run pytest locally - and update the schema enum to match — these two files must stay in - sync because the same allowlist is referenced from both human-facing - docs and from CI manifest validation. +3. The `license` enum in the schema enforces AGPL-3.0-only for + contributed manifests. 4. The schema accepts capability-pipelines.v1 manifest metadata so native capability declarations stay first-class in tooling. """ @@ -69,6 +65,7 @@ def test_schema_contains_capability_contract(schema: dict) -> None: assert "diagnostic-only" in declaration["properties"]["safety"]["enum"] assert "styles" in schema["properties"] assert schema["properties"]["styles"]["pattern"].startswith("^assets/") + assert "pluginRelpath" in schema["$defs"] def test_docs_schema_capability_contract_matches_ci_schema(schema: dict, docs_schema: dict) -> None: @@ -80,8 +77,9 @@ def test_docs_schema_capability_contract_matches_ci_schema(schema: dict, docs_sc return [without_descriptions(item) for item in value] return value - for key in ("standards", "capability_api", "capabilities", "ui", "ui_contributions", "runtime_domains", "domains", "settings_schema", "styles"): + for key in ("standards", "capability_api", "capabilities", "ui", "ui_contributions", "runtime_domains", "domains", "settings_schema", "styles", "screen", "script", "routes", "tour", "settings"): assert without_descriptions(docs_schema["properties"][key]) == without_descriptions(schema["properties"][key]) + assert without_descriptions(docs_schema["$defs"]["pluginRelpath"]) == without_descriptions(schema["$defs"]["pluginRelpath"]) assert without_descriptions(docs_schema["$defs"]["domainName"]) == without_descriptions(schema["$defs"]["domainName"]) assert without_descriptions(docs_schema["$defs"]["capabilityDeclaration"]) == without_descriptions(schema["$defs"]["capabilityDeclaration"]) assert without_descriptions(docs_schema["$defs"]["domainDeclaration"]) == without_descriptions(schema["$defs"]["domainDeclaration"]) @@ -148,6 +146,7 @@ def test_capability_manifest_metadata_validates(schema: dict) -> None: "id": "capability_example", "name": "Capability Example", "version": "0.1.0", + "license": "AGPL-3.0-only", "standards": ["capability-pipelines.v1", "plugin-runtime-idempotent.v1"], "script": "screen.js", "settings": {"html": "settings.html"}, @@ -214,48 +213,35 @@ def test_invalid_capability_metadata_fails_schema(schema: dict) -> None: jsonschema.validate(manifest, schema) -def _extract_allowlist_from_contributing() -> set[str]: - """Pull the curated-license allowlist out of CONTRIBUTING.md. +def test_schema_license_enum_requires_agpl_only(schema: dict) -> None: + """Contributed manifests must not validate with non-AGPL licenses.""" + assert schema["properties"]["license"]["enum"] == ["AGPL-3.0-only"] - Looks at the "Plugin licensing" section: any bullet line whose text - starts with a recognized SPDX-shape identifier is considered part of - the allowlist. Forms like "AGPL-3.0-only or AGPL-3.0-or-later" are - split on " or ". - """ - text = CONTRIBUTING_PATH.read_text(encoding="utf-8") - section = text.split("## Plugin licensing", 1) - if len(section) < 2: - pytest.fail("'## Plugin licensing' section not found in CONTRIBUTING.md") - body = section[1].split("\n## ", 1)[0] - - spdx_re = re.compile(r"^[A-Za-z0-9.+-]+$") - allowlist: set[str] = set() - for line in body.splitlines(): - if not line.lstrip().startswith("- "): - continue - rest = line.lstrip()[2:].strip() - # Strip trailing punctuation / parenthetical notes. - rest = re.split(r"\s*\(|\s*—|\s*--", rest)[0].strip().rstrip(".,;") - for token in re.split(r"\s+or\s+|\s*/\s+|\s*,\s+", rest): - token = token.strip().rstrip(".,;").strip() - if token and spdx_re.match(token): - allowlist.add(token) - return allowlist + manifest = {"id": "license_example", "name": "License Example", "license": "MIT"} + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(manifest, schema) -def test_schema_license_enum_subset_of_contributing_allowlist(schema: dict) -> None: - """Schema's license enum must be ⊆ CONTRIBUTING.md curated allowlist. +def test_plugin_runtime_paths_are_plugin_relative(schema: dict) -> None: + """Runtime file path fields must reject escapes and URL suffixes.""" + valid = { + "id": "path_example", + "name": "Path Example", + "screen": "screen.html", + "script": "assets/screen.js", + "routes": "routes.py", + "tour": "tours/intro.json", + "settings": {"html": "settings/settings.html"}, + } + jsonschema.validate(valid, schema) - If you add a license to the schema enum, also list it in - CONTRIBUTING.md "Plugin licensing". Direction matters: schema ⊆ - allowlist (the schema can be stricter than what CONTRIBUTING.md - documents — typically the schema *equals* the allowlist). - """ - license_enum = set(schema["properties"]["license"]["enum"]) - allowlist = _extract_allowlist_from_contributing() - missing = license_enum - allowlist - assert not missing, ( - f"License enum values present in schema/plugin.schema.json but " - f"not listed in CONTRIBUTING.md 'Plugin licensing' section: {sorted(missing)}. " - f"Update CONTRIBUTING.md or remove from the schema enum." - ) + for field in ("screen", "script", "routes", "tour"): + for bad_path in ("../escape.html", "safe/../escape.html", "/abs.html", "C:/abs.html", "dir\\file.js", "screen.html?x=1", "screen.html#frag", "./screen.html", ".hidden"): + manifest = {"id": "bad_path_example", "name": "Bad Path Example", field: bad_path} + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(manifest, schema) + + for bad_path in ("../settings.html", "settings/../settings.html", "/settings.html", "settings\\settings.html", "settings.html?x=1", "settings.html#frag", "./settings.html", ".settings.html"): + manifest = {"id": "bad_settings_path", "name": "Bad Settings Path", "settings": {"html": bad_path}} + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(manifest, schema)