|
| 1 | +# Skill catalog with signed manifest installs |
| 2 | + |
| 3 | +The skill catalog promotes the same browse / list / search / install / |
| 4 | +upgrade / info / status surface that `bernstein mcp catalog` already |
| 5 | +ships - this time for skill packs. Catalog entries point at installable |
| 6 | +sources (github, git, npm, file, directory) and carry a content digest |
| 7 | +plus an optional Ed25519 signature so the operator (or an auditor) can |
| 8 | +prove what bytes landed in `.bernstein/skills/`. |
| 9 | + |
| 10 | +## Commands |
| 11 | + |
| 12 | +``` |
| 13 | +bernstein skills catalog browse |
| 14 | +bernstein skills catalog list |
| 15 | +bernstein skills catalog search <query> |
| 16 | +bernstein skills catalog info <id> |
| 17 | +bernstein skills catalog install <id> [--allow-unverified] [--refresh] [--scope project|user] |
| 18 | +bernstein skills catalog upgrade <id> [--all] [--allow-unverified] |
| 19 | +bernstein skills catalog uninstall <id> |
| 20 | +bernstein skills catalog sync |
| 21 | +bernstein skills catalog status |
| 22 | +``` |
| 23 | + |
| 24 | +All commands honour `--scope project` (writes into |
| 25 | +`<cwd>/.bernstein/skills/`) or `--scope user` |
| 26 | +(`~/.bernstein/skills/`). The default is `project`. |
| 27 | + |
| 28 | +## Manifest schema |
| 29 | + |
| 30 | +A catalog entry is a strict JSON object. Unknown fields reject the |
| 31 | +fetch, identical to the MCP catalog schema: |
| 32 | + |
| 33 | +```json |
| 34 | +{ |
| 35 | + "id": "code-review", |
| 36 | + "name": "code-review", |
| 37 | + "version": "1.0.0", |
| 38 | + "description": "Review code diffs and surface risk hot-spots.", |
| 39 | + "source": { |
| 40 | + "kind": "github", |
| 41 | + "repo": "acme/code-review-skill", |
| 42 | + "tag": "v1.0.0" |
| 43 | + }, |
| 44 | + "content_digest": "<64-char hex SHA-256>", |
| 45 | + "signature": "<base64url Ed25519>", |
| 46 | + "verified": true, |
| 47 | + "tags": ["review", "security"] |
| 48 | +} |
| 49 | +``` |
| 50 | + |
| 51 | +Supported `source.kind` values: `github`, `git`, `npm`, `file`, |
| 52 | +`directory`. Each variant maps onto the existing |
| 53 | +[`plugin_installer`](../../src/bernstein/core/plugins_core/plugin_installer.py) |
| 54 | +implementation; the catalog does not introduce new download or extract |
| 55 | +logic. |
| 56 | + |
| 57 | +## Signature workflow |
| 58 | + |
| 59 | +1. The publisher generates an Ed25519 keypair via |
| 60 | + `bernstein.core.skills.catalog.generate_signer_keypair()` (thin |
| 61 | + wrapper around the lineage layer's existing primitive). |
| 62 | +2. The publisher signs each entry with `sign_entry(entry, private_pem)` |
| 63 | + and attaches the base64url-encoded signature on the `signature` |
| 64 | + field. |
| 65 | +3. The catalog publishes the matching public key on the top-level |
| 66 | + `signer_pubkey` field. |
| 67 | +4. The install path runs `verify_entry(entry, signer_pubkey)`. An entry |
| 68 | + without a signature, or with a signature that does not verify, is |
| 69 | + refused unless the operator passes `--allow-unverified`. An |
| 70 | + unverified install still proceeds but the audit event records |
| 71 | + `manifest_signer_pubkey=null`. |
| 72 | + |
| 73 | +The signed payload is the canonical JSON of the entry, deliberately |
| 74 | +excluding the `signature` and `verified` fields so the signature is |
| 75 | +neither self-referential nor sensitive to operator-side flags. |
| 76 | + |
| 77 | +## Audit chain integration |
| 78 | + |
| 79 | +Every install / upgrade / uninstall appends an HMAC-chained event under |
| 80 | +`.sdd/audit/`, reusing |
| 81 | +[`bernstein.core.security.audit.AuditLog`](../../src/bernstein/core/security/audit.py): |
| 82 | + |
| 83 | +| Event type | Payload fields | |
| 84 | +|----------------------------|---------------------------------------------------------------------------------------------------------------| |
| 85 | +| `skill.catalog.fetch` | `source_url`, `from_cache`, `revalidated` | |
| 86 | +| `skill.catalog.install` | `manifest_url`, `manifest_sha256`, `manifest_signer_pubkey`, `install_id`, `prev_chain_digest` | |
| 87 | +| `skill.catalog.upgrade` | `from_version`, `to_version`, `manifest_url`, `manifest_sha256`, `install_id`, `prev_chain_digest` | |
| 88 | +| `skill.catalog.uninstall` | (none) | |
| 89 | +| `skill.catalog.sync` | `lockfile_digest`, `lineage_receipt` | |
| 90 | + |
| 91 | +Reverting and re-running the chain pulls the identical manifest sha; the |
| 92 | +install refuses if the upstream sha drifted (a guard against silent |
| 93 | +upstream rewrites). |
| 94 | + |
| 95 | +## Lockfile and lineage receipts |
| 96 | + |
| 97 | +The lifecycle's `skills.lock` is extended with two additional TOML |
| 98 | +arrays: |
| 99 | + |
| 100 | +```toml |
| 101 | +[[catalog]] |
| 102 | +id = "code-review" |
| 103 | +name = "code-review" |
| 104 | +version = "1.0.0" |
| 105 | +manifest_url = "github://acme/code-review-skill@v1.0.0" |
| 106 | +manifest_sha256 = "..." |
| 107 | +content_digest = "..." |
| 108 | +install_id = "..." |
| 109 | +chain_head = "..." |
| 110 | +installed_at = "2026-05-21T00:00:00+00:00" |
| 111 | + |
| 112 | +[[lineage_receipt]] |
| 113 | +worktree_id = "..." |
| 114 | +action = "install" # one of: install, adopt, pin |
| 115 | +entry_id = "code-review" |
| 116 | +from_chain_head = "0000..." |
| 117 | +to_chain_head = "..." |
| 118 | +manifest_sha256 = "..." |
| 119 | +timestamp = "2026-05-21T00:00:00+00:00" |
| 120 | +``` |
| 121 | + |
| 122 | +Writes are atomic (`Path.replace` on a sibling `.tmp` file) so a |
| 123 | +concurrent reader either sees the old or the new state - never a partial |
| 124 | +write. Two parallel worktrees launched from the same chain head observe |
| 125 | +identical lockfile digests; an upgrade applied to one worktree produces |
| 126 | +a `RECEIPT_ADOPT` receipt that the sibling can consult to decide |
| 127 | +deterministically between `RECEIPT_ADOPT` (re-run the install) or |
| 128 | +`RECEIPT_PIN` (stay on the prior chain head). |
| 129 | + |
| 130 | +## CI lineage gate |
| 131 | + |
| 132 | +`bernstein.core.lineage.gate.check_skill_lockfile` extends the existing |
| 133 | +lineage-v1 gate (it does NOT add a new gate). The check passes when |
| 134 | +every `[[catalog]]` row's `manifest_sha256` is present in the audit |
| 135 | +chain's known-good set; a row whose sha is not anchored fails CI. |
| 136 | + |
| 137 | +```python |
| 138 | +from bernstein.core.lineage.gate import check_skill_lockfile |
| 139 | + |
| 140 | +result = check_skill_lockfile( |
| 141 | + Path("skills.lock"), |
| 142 | + frozenset(auditor.known_good_manifest_shas()), |
| 143 | +) |
| 144 | +if not result.ok: |
| 145 | + raise SystemExit("\n".join(result.failures)) |
| 146 | +``` |
| 147 | + |
| 148 | +## Cache and TTL |
| 149 | + |
| 150 | +The fetcher caches the upstream catalog under |
| 151 | +`.sdd/skills_catalog/catalog.json` (project-local). The cache TTL |
| 152 | +defaults to 6 hours; operators override it via |
| 153 | +`BERNSTEIN_SKILLS_CATALOG_TTL` (seconds). The cache and the audit log |
| 154 | +share a single source of truth: a stale fetch on a 5xx upstream serves |
| 155 | +the last validated copy instead of failing. |
| 156 | + |
| 157 | +## Drift detection |
| 158 | + |
| 159 | +`bernstein skills catalog sync` recomputes the on-disk content digest |
| 160 | +for every installed catalog skill and reports rows that do not match the |
| 161 | +lockfile. Drift indicates either a manual edit under |
| 162 | +`.bernstein/skills/<name>/` or an upstream rewrite; either is |
| 163 | +operator-actionable, never silently re-installed. |
0 commit comments