Skip to content

feat(plugins): publisher trust, signed catalog installs, and lock entries - #504

Merged
Ayinkx merged 1 commit into
AyinkxLab:mainfrom
olatechy0:feat/phase-9-publisher-trust-install-lock
Oct 17, 2026
Merged

Ayinkx merged 1 commit into
AyinkxLab:mainfrom
olatechy0:feat/phase-9-publisher-trust-install-lock

Conversation

@olatechy0

@olatechy0 olatechy0 commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Implements the Phase 9 plugin trust/install/lock slice: detached Ed25519 publisher signature verification, strict trusted-publisher key management, an owner-only catalog-backed remote install endpoint, and the PluginLockEntry model with canonical lockfile serialization.

Closes #385
Closes #386
Closes #389
Closes #394

Changes

#386 — trusted publisher keys (app/services/plugin_trust.py)

  • one strict loader validates PLUGIN_TRUSTED_KEYS: canonical lowercase publisher ids plus real 32-byte Ed25519 keys (base64 raw, PEM SPKI, or raw bytes).
  • invalid key material or an unknown PLUGIN_TRUST_POLICY now aborts startup with a clear error (create_app → validate_trust_config) instead of silently treating signatures as invalid.
  • secret-free read-only surfaces: flask plugins publishers [--json] and GET /plugins/api/trusted-publishers return publisher ids + policy only (no key bytes or fingerprints).

#385 — detached publisher signatures (app/services/plugin_signatures.py)

  • Ed25519 verification over the canonical manifest body (compact, key-sorted JSON excluding signature / trust_state / trust_publisher); the embedded and detached forms share one verifier.
  • policy: required rejects unsigned entries; forged, malformed, or unknown-publisher signatures are refused under both policies (absence is not forgery).
  • app/services/plugins.py now delegates to the shared helpers instead of duplicating the crypto path.

#389 — remote install from the catalog (app/services/plugin_catalog.py + route)

  • PLUGIN_CATALOG_FILE (empty by default = feature disabled) points at an operator-controlled catalog. Entries are validated at load: canonical id/version, https source, sha256 integrity, manifest id/version match, signature shape, duplicate detection, size/entry bounds.
  • POST /plugins/api/workspaces/<id>/plugins/install-from-catalog is owner-only (manage_plugins) and accepts only {plugin_id, version?} — never a client manifest, artifact URL, or key.
  • trust refusals are audited (plugin.denied with a static reason, no artifact bytes); success registers metadata only, downloads nothing, executes no code, and creates no capability grant. The response reports the resolved source and integrity.

#394 — plugin lock entries (PluginLockEntry)

  • owner/workspace-scoped rows, unique per (workspace_id, plugin_id), storing plugin id, version, source, sha256 integrity, dependencies, and resolved_at.
  • rows serialize through the existing canonical dumps_lockfile (sorted, normalized, no volatile timestamps); no capability grants are implied.

Docs: docs/plugins.md, docs/security.md, .env.example updated.

Scope notes:

Validation

  • New change suite: 107 passed (tests/test_plugin_trust.py, tests/test_plugin_signatures.py, tests/test_plugin_catalog_install.py, tests/test_plugin_lock_entries.py)
  • Existing plugin suites: 124 passed (test_plugins_manifest.py, test_plugin_lockfile.py, test_plugins_api.py, test_plugins_cli.py), including the embedded-signature regression tests
  • ruff check . — All checks passed!
  • black --check . — 270 files would be left unchanged.
  • Full pytest run complete — 2353 passed, 2 failed; both failures are pre-existing on main and unrelated (see below)
  • Docs updated if user-visible

New tests:

  • tests/test_plugin_trust.py (37) — key encodings, invalid configuration, startup rejection, secret-free API/CLI.
  • tests/test_plugin_signatures.py (24) — canonical payload, valid/forged/unsigned policy matrix, embedded-signature regression.
  • tests/test_plugin_catalog_install.py (39) — catalog validation plus endpoint authorization, trust policy, 4xx paths, audit of refusals, no grants, no code execution.
  • tests/test_plugin_lock_entries.py (7) — scoping, uniqueness, canonical lockfile bytes.

Pre-existing failures (not from this PR)

The full suite reports two failures, both in tests/test_notifications.py
(TestBudgetAlertDeduplication): they import app.services.budget_alerts,
which does not exist on origin/main either.

git ls-tree origin/main app/services/budget_alerts.py   # no output
git show origin/main:tests/test_notifications.py | grep budget_alerts
# from app.services.budget_alerts import check_budget_alerts

So the failure reproduces without this branch and is unrelated to the plugin
trust/install/lock changes. It looks like leftover wiring from the budget-alert
dedup work (#471) and should be fixed in its own PR.

CI confirms that this is pre-existing: the main run after #503
(gh run view 39044006397 --log-failed) reports the same two failures
(2 failed, 2246 passed) and the same PostgreSQL coverage gate
(total of 84 is less than fail-under=85). This PR's run
(https://github.com/AyinkxLab/ai-code-assistant/actions/runs/39047731044)
reports 2 failed, 2353 passed — the only failures are those two, and every
plugin test (new and existing) is green.

Notes for reviewers

  • Security behaviour: a forged signature is refused even under if-present; only absence is tolerated, which keeps local-path installs working under the default policy.
  • Operator-visible change: a malformed PLUGIN_TRUSTED_KEYS used to make every signature Invalid silently; it now fails startup with a descriptive error. That is the point of Phase 9: Trusted publisher key management #386, but worth a release note.
  • Identity binding: the catalog route reuses the local install helper, so entry-point binding, duplicate handling, and the "no implicit grant" invariant cannot diverge between the two install paths.
  • Version selection: omitting version picks the newest catalogued version by PEP 440 ordering; an explicitly requested version must exist.
  • Key rotation: installing an existing plugin refreshes the persisted trust_state / trust_publisher from the verified signature.

…ries

Implements the Phase 9 trust/install/lock slice:

- AyinkxLab#386: strict PLUGIN_TRUSTED_KEYS loading (canonical ids, real Ed25519 keys), startup validation via create_app, and secret-free publisher listings (flask plugins publishers, GET /plugins/api/trusted-publishers).
- AyinkxLab#385: detached Ed25519 manifest signature verification over the canonical manifest body, shared with the embedded-signature path; forged/untrusted signatures always fail, unsigned entries fail under PLUGIN_TRUST_POLICY=required.
- AyinkxLab#389: validated operator catalog (PLUGIN_CATALOG_FILE) and owner-only POST .../plugins/install-from-catalog; registers metadata only, audits trust refusals, never downloads code or grants capabilities.
- AyinkxLab#394: PluginLockEntry model (owner/workspace scoped) serialized through the canonical deterministic lockfile builder.

Docs and .env.example updated; tests cover keys, signatures, catalog install failure/authorization paths, and lock serialization.
@drips-wave

drips-wave Bot commented Oct 17, 2026

Copy link
Copy Markdown

@olatechy0 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Ayinkx
Ayinkx merged commit 78becf4 into AyinkxLab:main Oct 17, 2026
4 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants