Skip to content

feat(skills): catalog with signed manifest installs#1812

Merged
chernistry merged 2 commits into
mainfrom
feat/1796-skill-catalog-signed-manifests
May 21, 2026
Merged

feat(skills): catalog with signed manifest installs#1812
chernistry merged 2 commits into
mainfrom
feat/1796-skill-catalog-signed-manifests

Conversation

@chernistry

Copy link
Copy Markdown
Collaborator

Summary

Promote the MCP catalog browse / list / search / install / upgrade / info / status surface to skill packs. Source variants (github, git, npm, file, directory) resolve through the existing plugin_installer; manifests carry an Ed25519 signature that install refuses unless --allow-unverified is set. Each install appends a skill.catalog.install audit-chain event with (manifest_url, manifest_sha256, manifest_signer_pubkey, install_id, prev_chain_digest); replaying the chain pulls the identical sha and refuses if upstream drifted. skills.lock is extended with [[catalog]] rows and [[lineage_receipt]] rows so two parallel worktrees launched from the same chain head observe identical skill versions; an upgrade in one produces a deterministic adopt/pin decision in the other. The existing lineage-v1 gate is extended (not replaced) with check_skill_lockfile to reject PRs whose lockfile references a manifest sha not present in the chain's known-good set. Catalog cache lives under .sdd/skills_catalog/ with revalidation honouring BERNSTEIN_SKILLS_CATALOG_TTL.

Closes #1796

Files touched

  • src/bernstein/cli/commands/skills_catalog_cmd.py (new): CLI surface
  • src/bernstein/cli/main.py: wire bernstein skills catalog subgroup
  • src/bernstein/core/skills/catalog/__init__.py (new): public re-exports
  • src/bernstein/core/skills/catalog/manifest.py (new): strict schema + validator
  • src/bernstein/core/skills/catalog/signature.py (new): Ed25519 sign/verify
  • src/bernstein/core/skills/catalog/fetcher.py (new): HTTP fetcher + cache
  • src/bernstein/core/skills/catalog/audit.py (new): audit chain integration
  • src/bernstein/core/skills/catalog/installer.py (new): wraps plugin_installer
  • src/bernstein/core/skills/catalog/lockfile.py (new): atomic lockfile + receipts
  • src/bernstein/core/skills/catalog/service.py (new): high-level service
  • src/bernstein/core/lineage/gate.py: additive check_skill_lockfile
  • tests/unit/core/skills/test_catalog.py (new): 25 unit tests
  • tests/integration/test_skill_catalog_cross_worktree.py (new): 3 integration tests
  • docs/operations/skills-catalog.md (new): operator docs
  • CHANGELOG.md: Unreleased entry

Acceptance criteria

  • bernstein skills catalog exposes browse, list, search, install, upgrade <id> / --all, info, status modelled on bernstein mcp catalog
  • Each install appends an audit-chain entry with (manifest_url, manifest_sha256, manifest_signer_pubkey, install_id, prev_chain_digest); replay refuses on upstream sha drift
  • Manifests carry name, version, source URL, content digest, signature; install refuses unverified unless --allow-unverified
  • Catalog fetch cached under .sdd/skills_catalog/ with BERNSTEIN_SKILLS_CATALOG_TTL env override
  • Install resolves source variants via the existing plugin_installer and writes into .bernstein/skills/<name>/
  • skills.lock updates atomically; bernstein skills catalog sync detects drift; two parallel worktrees from the same chain head observe identical skill versions; upgrade in wt-a produces a lineage receipt that wt-b uses to decide adopt or pin
  • CI lineage gate rejects a PR whose lockfile references a manifest sha not in the audit chain's known-good set
  • Tests cover browse render, search match, signature-failed refusal, install-from-github fixture, drift detection, cross-worktree consistency, CI gate accept/reject

Test plan

  • uv run pytest tests/unit/core/skills/test_catalog.py tests/integration/test_skill_catalog_cross_worktree.py (28 tests pass)
  • uv run pytest tests/unit/skills/ (250 existing skill tests pass)
  • uv run pytest tests/unit/lineage/test_gate.py (23 lineage-gate tests pass)
  • uv run pytest tests/unit -k "skill or catalog" (603 tests pass)
  • uv run ruff check clean on all new/touched files
  • bernstein skills catalog --help exposes all subcommands

Promote the MCP catalog browse / list / search / install / upgrade /
info / status surface to skill packs. Source variants (github, git,
npm, file, directory) resolve through the existing plugin_installer;
catalog manifests carry an Ed25519 signature that the install verifies
against the catalog's signer_pubkey. Every install / upgrade appends a
skill.catalog.install event to the HMAC-chained audit log with
(manifest_url, manifest_sha256, manifest_signer_pubkey, install_id,
prev_chain_digest); replaying the chain pulls the identical sha and
refuses installation if the upstream sha drifted. skills.lock is
extended with [[catalog]] rows and [[lineage_receipt]] rows so two
parallel worktrees launched from the same chain head observe identical
skill versions, and an upgrade in one worktree produces a deterministic
adopt/pin decision in the other. The existing lineage-v1 gate is
extended with check_skill_lockfile to reject PRs whose lockfile
references a manifest sha not present in the chain's known-good set.
Catalog cache lives under .sdd/skills_catalog/ with revalidation
honouring BERNSTEIN_SKILLS_CATALOG_TTL.

Closes #1796

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @chernistry, you have reached your weekly rate limit of 2500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented May 21, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@chernistry has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 48 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7a1e6365-30cc-47b0-b9c4-5328517c7277

📥 Commits

Reviewing files that changed from the base of the PR and between 6160ea6 and 2f73384.

⛔ Files ignored due to path filters (1)
  • CHANGELOG.md is excluded by !CHANGELOG.md
📒 Files selected for processing (16)
  • docs/operations/skills-catalog.md
  • src/bernstein/cli/commands/skills_catalog_cmd.py
  • src/bernstein/cli/main.py
  • src/bernstein/core/lineage/gate.py
  • src/bernstein/core/skills/catalog/__init__.py
  • src/bernstein/core/skills/catalog/audit.py
  • src/bernstein/core/skills/catalog/fetcher.py
  • src/bernstein/core/skills/catalog/installer.py
  • src/bernstein/core/skills/catalog/lockfile.py
  • src/bernstein/core/skills/catalog/manifest.py
  • src/bernstein/core/skills/catalog/service.py
  • src/bernstein/core/skills/catalog/signature.py
  • tests/integration/test_skill_catalog_cross_worktree.py
  • tests/unit/core/__init__.py
  • tests/unit/core/skills/__init__.py
  • tests/unit/core/skills/test_catalog.py
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/1796-skill-catalog-signed-manifests

Comment @coderabbitai help to get the list of available commands and usage tips.

@chernistry chernistry enabled auto-merge (squash) May 21, 2026 20:33
@github-actions

Copy link
Copy Markdown
Contributor

Sonar insights (advisory, no merge-block)

Snapshot of bernstein on the configured Sonar instance:

Metric Value
Coverage 13.5
Code smells 106
Bugs 11
Vulnerabilities 2
Security hotspots 87

Run bernstein doctor sonar locally for the full surface.

This comment is a soft signal. The Sonar scan runs on push to main; the PR check itself never fails on smells.

@github-actions

Copy link
Copy Markdown
Contributor

Review-bot acknowledgement summary

  • Must-address findings: 0 (0 acknowledged, 0 open)
  • Informational findings: 0

All must-address findings are resolved or acknowledged.

@github-actions

Copy link
Copy Markdown
Contributor

bernstein doctor observe for PR #1812 (feat/1796-skill-catalog-signed-manifests): ok=0, warn=2, fail=0, error=0, skipped=2

sonar -- WARN (project bernstein)

metric value delta threshold status
coverage_pct 13.5% new 80.0% fail
code_smells 106 new 50 warn
bugs 11 new 0 fail
vulnerabilities 2 new 0 warn
security_hotspots 87 new 0 fail

code-scanning -- WARN (27 open alert(s))

metric value delta threshold status
open_alerts 27 new 0 fail
critical_alerts 0 new 0 ok
high_alerts 2 new 0 warn
medium_alerts 0 new - ok
low_alerts 0 new - ok
Skipped backends (credentials not configured)
  • glitchtip: BERNSTEIN_GLITCHTIP_TOKEN not set
  • dt: DTRACK_URL/TOKEN/PROJECT not set

See docs/observability/unified-doctor.md for backend setup notes.

@chernistry chernistry merged commit 2d5b3a3 into main May 21, 2026
27 of 29 checks passed
@chernistry chernistry deleted the feat/1796-skill-catalog-signed-manifests branch May 21, 2026 20:34
@github-actions

github-actions Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

Mutation gate (fixed critical paths)

Module Kill rate Threshold Killed/Total Status Notes
audit_integrity 100.0% 70% 38/38 -
audit_log 100.0% 70% 57/57 -
claim_next 84.9% 70% 45/53 8 survivors
config_seed_parser 95.7% 70% 45/47 budget exceeded; 2 survivors
lineage_gate 65.1% 75% 28/43 15 survivors
lineage_merge 75.0% 75% 6/8 2 survivors
lineage_tips 93.8% 75% 15/16 1 survivors
Surviving mutations (top 20)
  • lineage_gate: src/bernstein/core/lineage/gate.py:37 'True' -> 'False': """Outcome of check. ok is True iff failures is empty."""
  • lineage_gate: src/bernstein/core/lineage/gate.py:151 'not ' -> '': failures.append(f"{entry.artefact_path}: cannot read signature {sig_path}: {exc}")
  • lineage_gate: src/bernstein/core/lineage/gate.py:166 'not ' -> '': f"{entry.artefact_path}: merge entry {eh} written by non-steward {entry.agent_id!r} (not in allowlist)"
  • lineage_gate: src/bernstein/core/lineage/gate.py:178 ' 1' -> ' 2': if len(tipset["open"]) > 1:
  • lineage_gate: src/bernstein/core/lineage/gate.py:179 'len(' -> '0 * len(': failures.append(f"{path}: {len(tipset['open'])} unresolved open tips: {tipset['open']}")
  • lineage_gate: src/bernstein/core/lineage/gate.py:186 ' and ' -> ' or ': if len(entry.parent_hashes) >= 2 and children.issubset(set(entry.parent_hashes)):
  • lineage_gate: src/bernstein/core/lineage/gate.py:186 ' 2' -> ' 1': if len(entry.parent_hashes) >= 2 and children.issubset(set(entry.parent_hashes)):
  • lineage_gate: src/bernstein/core/lineage/gate.py:230 'not ' -> '': if not lockfile_path.is_file():
  • lineage_gate: src/bernstein/core/lineage/gate.py:231 'True' -> 'False': return GateResult(ok=True, failures=[])
  • lineage_gate: src/bernstein/core/lineage/gate.py:231 '[]' -> '[None]': return GateResult(ok=True, failures=[])
  • lineage_gate: src/bernstein/core/lineage/gate.py:237 'False' -> 'True': return GateResult(ok=False, failures=["skill catalog lockfile module is missing"])
  • lineage_gate: src/bernstein/core/lineage/gate.py:240 '[]' -> '[None]': failures: list[str] = []
  • lineage_gate: src/bernstein/core/lineage/gate.py:242 'not ' -> '': if row.manifest_sha256 not in known_good_manifest_shas:
  • lineage_gate: src/bernstein/core/lineage/gate.py:245 'not ' -> '': "is not present in the audit chain's known-good set",
  • lineage_gate: src/bernstein/core/lineage/gate.py:247 'not ' -> '': return GateResult(ok=not failures, failures=failures)

Gate is advisory while thresholds stabilise. To kill survivors locally:
uv run python scripts/mutmut_critical.py --only <module>

@github-actions

Copy link
Copy Markdown
Contributor

Contract drift detected - proposed patch

Inline autofix push failed (failure). Apply the patch below manually.

Three contract tests act as drift detectors against the public CLI / API surface:

  • tests/unit/test_readme_api_coverage.py::test_all_cli_commands_are_documented
  • tests/unit/test_api_v1_routing.py::TestVersionedRoutesParity::test_every_root_route_has_v1_counterpart
  • tests/unit/test_cli_run_params.py::test_run_params_match_cli_call

One or more failed on this PR. scripts/regen_contract_drift.py produced the patch below (2 LOC, cap: 30).

Files changed:

tests/unit/test_readme_api_coverage.py

How to apply

Either run the regen script locally:

uv run python scripts/regen_contract_drift.py --fixture all
git add -A && git commit -m "chore(ci): regenerate contract drift allow-lists"
git push

Or apply the patch directly:

gh pr checkout 1812
git apply <<'PATCH'
diff --git a/tests/unit/test_readme_api_coverage.py b/tests/unit/test_readme_api_coverage.py
index fa6b08a6..90b3d71d 100644
--- a/tests/unit/test_readme_api_coverage.py
+++ b/tests/unit/test_readme_api_coverage.py
@@ -239,6 +239,8 @@ DOCUMENTED_COMMANDS: frozenset[str] = frozenset(
         "desktop-register",
         # Bot-added: drift autofix (regen_contract_drift.py)
         "supervisor",
+        # Bot-added: drift autofix (regen_contract_drift.py)
+        "schedule",
     }
 )
PATCH
git add -A && git commit -m "chore(ci): regenerate contract drift allow-lists"
git push
Full diff
diff --git a/tests/unit/test_readme_api_coverage.py b/tests/unit/test_readme_api_coverage.py
index fa6b08a6..90b3d71d 100644
--- a/tests/unit/test_readme_api_coverage.py
+++ b/tests/unit/test_readme_api_coverage.py
@@ -239,6 +239,8 @@ DOCUMENTED_COMMANDS: frozenset[str] = frozenset(
         "desktop-register",
         # Bot-added: drift autofix (regen_contract_drift.py)
         "supervisor",
+        # Bot-added: drift autofix (regen_contract_drift.py)
+        "schedule",
     }
 )

Source CI run: https://github.com/sipyourdrink-ltd/bernstein/actions/runs/26251528553

Refs #1273.

from rich.table import Table

service = _build_service(scope)
rows = service.list_installed()
def info_cmd(entry_id: str, refresh: bool, scope: str) -> None:
"""Show full info for a single catalog entry."""
service = _build_service(scope)
entry = service.info(entry_id, force_refresh=refresh)
def sync_cmd(scope: str) -> None:
"""Detect lockfile vs on-disk drift."""
service = _build_service(scope)
drift = service.sync()
def status_cmd(scope: str) -> None:
"""Show cache + lockfile state for ``skills catalog``."""
service = _build_service(scope)
status = service.status()
return GateResult(ok=False, failures=["skill catalog lockfile module is missing"])

state = read_state(lockfile_path)
failures: list[str] = []
swaps the upstream while keeping the URL is caught.
"""
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
hasher = hashlib.sha256()
swaps the upstream while keeping the URL is caught.
"""
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
hasher = hashlib.sha256()
plugin_installer: InstallerCallable | None = None,
preloaded_catalog: SkillCatalog | None = None,
) -> None:
if fetcher is None and preloaded_catalog is None:
) -> UpgradeOutcome:
"""Upgrade a single installed catalog entry."""
state = read_state(self.lockfile_path)
prior = state.find_catalog(entry_id)
raise SkillCatalogError(f"{entry_id!r} is not installed")

catalog = self.browse(force_refresh=force_refresh)
upstream = catalog.find(entry_id)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Skill catalog with signed manifest installs

2 participants