@skill-map/spec 0.62.1 → 0.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Spec changelog
2
2
 
3
+ ## 0.63.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Split plugin enable (operational) from import trust (security). Enable/disable now persist to the config layers, not the DB; `config_plugins` becomes a per-plugin local trust store. New `sm plugins trust / untrust` verbs, a trust PATCH route, a Settings UI Trust control, and a `pluginTrust.projectEnabled` opt-in grant or revoke consent to run a project-local plugin. It runs only when enabled AND trusted, so disabling one no longer re-reads as untrusted.
8
+
9
+ ## User-facing
10
+
11
+ Plugins now have two separate switches: enable (is it part of the project, shared) and trust (may its code run on your machine). New `sm plugins trust` / `untrust` plus a Trust button in Settings. A plugin you disabled stays disabled instead of nagging that it is untrusted.
12
+
3
13
  ## 0.62.1
4
14
 
5
15
  ### Patch Changes
package/architecture.md CHANGED
@@ -411,7 +411,7 @@ In addition to the `emitLink` path, Extractors MAY emit **Signals** via `ctx.emi
411
411
 
412
412
  The kernel's **resolver phase** runs after extraction completes and before analysis starts. For each Signal, the resolver:
413
413
 
414
- 1. (Phase 4+, not yet wired) Filters candidates whose `extractorId` is disabled by a per-extension enable filter. The config surface that toggles individual extensions is not defined yet (the earlier `plugins.<id>.extensions.<extId>.enabled` placeholder was removed); it will be specified when this filter lands. When the filter empties every candidate, the Signal carries `resolution.outcome = 'rejected'` with `extractorDisabled = { extractorId }`.
414
+ 1. (Signal-resolver candidate filter not yet wired) Filters candidates whose `extractorId` is disabled by a per-extension enable filter. The per-extension enable config surface now exists (`plugins.<id>.extensions.<ext>.enabled`, see [`project-config.schema.json`](./schemas/project-config.schema.json) and §Plugin enable vs import trust) and gates extension registration at load time; the Signal-resolver candidate filter that consults it per detection is not wired yet. When the filter empties every candidate, the Signal carries `resolution.outcome = 'rejected'` with `extractorDisabled = { extractorId }`.
415
415
  2. Ranks surviving candidates inside the Signal by the active Provider's `resolverRules.kindPriority` (when declared), then `confidence` DESC, then `range` length (`end - start`) DESC, then `extractorId` declaration order. The chosen index is recorded as `resolution.winnerIndex` and (provisionally) `resolution.outcome = 'materialised'`.
416
416
  3. For body-scoped Signals with a `range`, the resolver builds overlap clusters per source (transitive closure of range intersection). Size-1 clusters keep their winner. For size 2+ clusters, the resolver re-applies the same four-step tiebreak to each Signal's winning candidate to pick a cluster winner. Losers flip to `resolution.outcome = 'rejected'` with `rejectedBy = { source, range, extractorId, reason }`, where `reason` names the deciding tiebreak step: `kind-priority`, `higher-confidence`, `longer-range`, or `earlier-declaration`. External pseudo-link clusters (every member targets `http://` / `https://`) skip cross-cluster ranking, every member materialises (URL-targeted Signals never conflict with internal-target Signals or each other because they leave the local graph).
417
417
  4. Materialises every Signal whose final `outcome === 'materialised'` as a Link, identical in shape to one emitted directly via `emitLink`. The materialised Link's `sources[]` carries the winning candidate's `extractorId` so attribution survives.
@@ -558,7 +558,11 @@ Hooks introduce no new persisted state and do NOT participate in the determinist
558
558
  ### Locality
559
559
 
560
560
  - **Drop-in**: extensions live inside plugins, discovered at boot from `<cwd>/.skill-map/plugins/<id>/` only. The `--plugin-dir <path>` escape hatch on the `sm plugins …` verb family loads a custom directory per invocation when the user explicitly opts in.
561
- - **Import trust (security boundary).** A drop-in plugin discovered under the project-local `<cwd>/.skill-map/plugins/` is parsed (manifest read + surfaced in `sm plugins list`) but its extension CODE is NOT imported or executed until the operator grants LOCAL trust: a `config_plugins` (DB) override enabling the plugin (`<id>`) or any of its extensions (`<id>/<ext>`), as written by `sm plugins enable <id>` or the Settings toggle. The committed `settings.json` baseline does NOT grant import trust: a cloned repo controls its own `.skill-map/settings.json`, so honouring it would let a hostile repo auto-execute its plugins on the victim's first `sm scan`. A fresh clone therefore ships its project-local plugins discovered-but-unexecuted; the runtime emits a one-time notice naming how many were found and pointing at `sm plugins enable`. Built-in extensions (compiled into the CLI) and an explicit `--plugin-dir <path>` (the operator pointed the loader at the code on purpose) are NOT gated; `--no-plugins` skips discovery entirely. The `sm plugins` management family (`list` / `show` / `enable` / `disable` / `doctor`) still imports discovered plugin code to enumerate extensions, running those verbs is itself the operator's explicit choice to work with the project's plugins.
561
+ - **Plugin enable vs import trust (security boundary, two orthogonal axes).** A drop-in plugin discovered under the project-local `<cwd>/.skill-map/plugins/` is parsed (manifest read + surfaced in `sm plugins list`) but its extension CODE is imported and executed only when BOTH axes allow it:
562
+ - **Enabled (operational, shareable).** Whether the plugin / extension is part of the project. Lives in the config layers (`plugins.<id>.enabled`, `plugins.<id>.extensions.<ext>.enabled`), `settings.json` (committed team baseline) overlaid by `settings.local.json` (per-checkout override). Written by `sm plugins enable / disable` (defaults to the shared `settings.json`; `--local` targets `settings.local.json`) and the Settings toggle. Default: the installed default (`true` for `stable` / `beta`, `false` for `experimental` / `deprecated`).
563
+ - **Trusted (security, LOCAL, per-machine).** Whether THIS machine's operator consents to importing the plugin's code. A per-plugin boolean in the `config_plugins` (DB) trust store, written by `sm plugins trust / untrust <id>` and the per-plugin Trust control in the UI. The DB is structurally local (never committed, not a config layer), so trust cannot travel in a clone. A committed `settings.json` does NOT and CANNOT grant import trust: honouring a shared file would let a hostile repo auto-execute its plugins on the victim's first `sm scan`.
564
+
565
+ A plugin's code is imported iff it is **enabled** AND (it carries a local **trust** grant OR the local opt-in `pluginTrust.projectEnabled` is set). Per-extension enable is applied after import, at registration. A fresh clone has no DB trust row and no local opt-in, so its project-local plugins are discovered-but-unexecuted (`status: 'disabled'`, `untrusted: true`); the runtime emits a one-time notice naming how many were found and pointing at `sm plugins trust <id>`. The local escape hatch `pluginTrust.projectEnabled` (in `settings.local.json` only, stripped from the committed layer, gated behind a confirm because it expands the local execution surface) trusts every plugin the project enables, for teams that vet plugins in code review. The loader keeps the two not-loaded reasons distinct: `disabledByConfig` (the operator turned it off) vs `untrustedNotLoaded` (no local trust grant), so an explicit disable never re-reads as untrusted across a restart. Built-in extensions (compiled into the CLI) and an explicit `--plugin-dir <path>` (the operator pointed the loader at the code on purpose) are NOT trust-gated; `--no-plugins` skips discovery entirely. The `sm plugins` management family (`list` / `show` / `enable` / `disable` / `trust` / `untrust` / `doctor`) still imports discovered plugin code to enumerate extensions, running those verbs is itself the operator's explicit choice to work with the project's plugins.
562
566
  - **Built-in**: the reference impl bundles a default extension set (one Provider, four extractors, five analyzers, one formatter, one hook). The fifth analyzer, `core/schema-violation`, replays every scanned node and link through the authoritative spec schemas via AJV, the kernel-side guard against persisting non-conforming graph rows. The first built-in Hook is `core/update-check`, subscribing to `shutdown` to run the once-per-day "update available" probe + banner that lived on the CLI entry path before the Hook kind had concrete consumers. Loaded from `src/extensions/`, these are indistinguishable from plugin-supplied extensions to the kernel.
563
567
 
564
568
  ---
@@ -654,8 +658,9 @@ One locality class constrains which layers a given key MAY live in. Enforced in
654
658
  Members:
655
659
  - `allowEditSmFiles`, per-project consent to create / modify `.sm` sidecars.
656
660
  - `scan.referencePaths`, additional link-validation paths.
661
+ - `pluginTrust.projectEnabled`, the local opt-in that trusts every plugin the project enables (the import-trust escape hatch).
657
662
 
658
- Both describe disk access the local operator opted into; sharing them via the repo would silently expand every collaborator's scan surface to paths that only make sense on the original author's machine.
663
+ The first two describe disk access the local operator opted into, the third the local code-execution surface; sharing any of them via the repo would silently expand every collaborator's surface (scan paths, or auto-running the repo's plugins) in a way only the original author consented to. `pluginTrust.projectEnabled` in particular MUST stay local: honouring a committed `true` would let a cloned repo auto-execute its own plugins, the exact supply-chain attack the import-trust gate prevents.
659
664
 
660
665
  Adding a new entry is a behaviour change for older installs that wrote the key into a committed file: the value gets stripped at read time. The changeset adding the entry MUST document the migration.
661
666
 
package/cli-contract.md CHANGED
@@ -52,9 +52,13 @@ external location MAY be loaded via the `--plugin-dir <path>` escape
52
52
  hatch on the `sm plugins …` verb family, user-explicit per invocation.
53
53
  Project-local plugins are discovered but their code is NOT executed by
54
54
  `sm scan` / `sm serve` (and the other runtime verbs) until the operator
55
- grants local trust via `sm plugins enable <id>`; the committed
56
- `settings.json` does not grant it. `--plugin-dir` and built-ins are not
57
- gated. See [`architecture.md` §Locality](./architecture.md) (import
55
+ grants LOCAL trust via `sm plugins trust <id>` (a per-plugin row in the
56
+ `config_plugins` DB trust store, or the local opt-in
57
+ `pluginTrust.projectEnabled`); the committed `settings.json` does not and
58
+ cannot grant it. Enable / disable (`sm plugins enable / disable`, persisted
59
+ in the config layers) is the separate OPERATIONAL axis and grants no trust.
60
+ `--plugin-dir` and built-ins are not gated. See
61
+ [`architecture.md` §Locality](./architecture.md) (plugin enable vs import
58
62
  trust) for the normative model.
59
63
 
60
64
  ### User-settings file (narrow, documented exception)
@@ -295,9 +299,9 @@ Keys are dot-paths (`jobs.minimumTtlSeconds`, `scan.tokenize`). Unknown keys →
295
299
 
296
300
  #### Privacy-sensitive config
297
301
 
298
- Keys whose value opens disk access OUTSIDE the project root (today: `scan.extraFolders`, `scan.referencePaths`) are gated behind `--yes` so the user never expands the scan surface by accident. The analyzer:
302
+ Keys whose value expands the project's surface, either disk access OUTSIDE the project root (today: `scan.extraFolders`, `scan.referencePaths`) or the local code-execution surface (`pluginTrust.projectEnabled`, which locally trusts every plugin the project enables), are gated behind `--yes` so the user never expands the surface by accident. The analyzer:
299
303
 
300
- - `sm config set <privacy-key> <value>` (without `--yes`), when the new value would expand the surface (adding `extraFolders` / `referencePaths` paths resolving outside the project root), exits with code `2` and prints the full list of exposed paths to stderr, suggesting `--yes` to confirm.
304
+ - `sm config set <privacy-key> <value>` (without `--yes`), when the new value would expand the surface (adding `extraFolders` / `referencePaths` paths resolving outside the project root, or setting `pluginTrust.projectEnabled` to `true`), exits with code `2` and prints the affected detail to stderr (the exposed paths, or the list of currently-untrusted plugins it would trust), suggesting `--yes` to confirm.
301
305
  - `sm config set <privacy-key> <value> --yes`, proceeds and prints the same list as a confirmation receipt.
302
306
  - Writes that NARROW the surface (removing paths) do not require `--yes`.
303
307
 
@@ -305,7 +309,7 @@ The Settings UI's Project section enforces the same analyzer via a confirm dialo
305
309
 
306
310
  #### Project-local-only config
307
311
 
308
- The three privacy-sensitive keys above PLUS `allowEditSmFiles` are members of `PROJECT_LOCAL_ONLY_KEYS` (see [`architecture.md` §Config layering · Per-key locality](./architecture.md#per-key-locality)). The values are per-user-per-project and MUST NOT travel via the committed repo:
312
+ The three privacy-sensitive keys above PLUS `allowEditSmFiles` and `pluginTrust.projectEnabled` are members of `PROJECT_LOCAL_ONLY_KEYS` (see [`architecture.md` §Config layering · Per-key locality](./architecture.md#per-key-locality)). The values are per-user-per-project and MUST NOT travel via the committed repo:
309
313
 
310
314
  - `sm config set` writes them to `<cwd>/.skill-map/settings.local.json` (gitignored).
311
315
  - The loader strips them (with a warning) when found in the committed `project` layer (`settings.json`). An older install that wrote one to `settings.json` keeps validating against the schema, but the value is ignored at read time and `sm config show --source` surfaces the warning.
@@ -561,8 +565,10 @@ Authentication: the nonce is the sole credential. An implementation MUST reject
561
565
  |---|---|
562
566
  | `sm plugins list [<id>]` | No id: auto-discovered plugins with status, one row per plugin (`--json` emits the aggregate discovered-plugin registry). With a bare plugin id: that plugin's manifest plus its extension detail (kind / version / per-extension status; `--json` emits the single `DiscoveredPlugin`). A qualified `<plugin>/<ext>` id is rejected with a redirect to `sm plugins show`. |
563
567
  | `sm plugins show <plugin>/<ext>` | Single-extension detail (Kind / Version / Stability / Description / Preconditions / Entry; `--json` emits the single extension object). Accepts only a qualified `<plugin>/<ext>` id; a bare plugin id is rejected with a redirect to `sm plugins list <id>`. |
564
- | `sm plugins enable <id>... \| --all` | Toggle on. Persists in `config_plugins`. Accepts one or more ids; batches are all-or-nothing (any unknown / mismatched id aborts before any write) and repeated ids are deduped. `--all` applies to every discovered plugin. |
565
- | `sm plugins disable <id>... \| --all` | Toggle off; does not delete the plugin directory. Eagerly purges each id's rows from `scan_contributions` so its UI chips disappear before the next scan (plugin-managed state in `state_plugin_kvs` / dedicated tables is preserved, see `plugin-kv-api.md`). Accepts one or more ids; batches are all-or-nothing and repeated ids are deduped. `--all` applies to every discovered plugin. |
568
+ | `sm plugins enable <id>... \| --all [--local]` | Operational toggle ON. Persists the per-extension `enabled` in the config layers (`plugins.<id>.extensions.<ext>.enabled`), defaulting to the shared `settings.json`; `--local` writes `settings.local.json` instead. Does NOT grant import trust (use `sm plugins trust`). Accepts one or more ids; batches are all-or-nothing (any unknown / mismatched id aborts before any write) and repeated ids are deduped. `--all` applies to every discovered plugin. |
569
+ | `sm plugins disable <id>... \| --all [--local]` | Operational toggle OFF; does not delete the plugin directory and does not revoke trust. Persists `enabled: false` in the config layers (`--local` targets `settings.local.json`). Eagerly purges each id's rows from `scan_contributions` so its UI chips disappear before the next scan (plugin-managed state in `state_plugin_kvs` / dedicated tables is preserved, see `plugin-kv-api.md`). Accepts one or more ids; batches are all-or-nothing and repeated ids are deduped. `--all` applies to every discovered plugin. |
570
+ | `sm plugins trust <id>... \| --all` | Grant LOCAL import trust: the operator's security consent to import and run this plugin's code on this machine. Persists a per-plugin row in the `config_plugins` (DB) trust store, keyed by the bare plugin id (a qualified `<plugin>/<ext>` collapses to its plugin). Local only, never committed, so it cannot travel in a clone. Distinct from `enable`: a plugin runs only when it is both enabled (config) and trusted. Accepts one or more ids; batches are all-or-nothing and repeated ids are deduped. `--all` applies to every discovered plugin. |
571
+ | `sm plugins untrust <id>... \| --all` | Revoke local import trust: drops the plugin's `config_plugins` trust row, so it reverts to discovered-but-unexecuted on the next scan / restart. Does NOT change the enable state and does NOT delete the plugin directory. Same id / batch semantics as `trust`. |
566
572
  | `sm plugins config <plugin>/<ext> [<settingId> [<value>]] [--reset]` | Read or write the operator-supplied values for an extension's declared `settings`. No `settingId`: table of each declared setting with its effective value and the layer that set it (`--json` emits the resolved set). With `<settingId> <value>`: coerce the shell string to the setting's input-type, validate, then write under `plugins.<pluginId>.extensions.<extId>.settings.<settingId>` (a normal setting lands in `settings.json`; a `secret`-typed one is forced into `settings.local.json`, gitignored, never committed); prints a "re-scan to apply" reminder. `--reset` drops the override back to the manifest default. Requires a qualified `<plugin>/<ext>` id; a bare plugin id is rejected with a redirect to `sm plugins list <id>`. `secret` values are redacted as `<redacted>` in output. |
567
573
  | `sm plugins doctor` | Revalidate all plugins against current spec version; update `status` fields. `--json` emits the report shape declared by [`plugins-doctor.schema.json`](./schemas/plugins-doctor.schema.json): `{ ok: true, kind: 'plugins.doctor', counts, issues[], warnings[], elapsedMs }`. |
568
574
 
@@ -630,10 +636,11 @@ The reference implementation ships a Hono BFF rooted at `src/server/`. One Node
630
636
  | `GET /api/branch?path=<prefix>&path=<prefix>&limit=<n>` | implemented | Branch projection for the map. `path` is **repeatable**: the response is the UNION of the subtrees under every given prefix (forward-slash; a node matches a prefix when its path equals it or starts with `<prefix>/`). No `path` (or a single empty one) = the whole corpus. The union is capped at `limit` nodes (default and effective max = the scan's `maxRenderNodes`), so the response stays bounded regardless of how many prefixes are sent. Direct shape (no envelope wrap, like `/api/scan`): `{ schemaVersion, kind: 'branch', branch: { paths, total, rendered, truncated, cap }, nodes: Node[], links: Link[], issues: Issue[] }`, where `paths` echoes the requested prefixes. `nodes` is the first `rendered` nodes of the union in stable path order; `links` carries only edges whose source AND **resolved target** are in `nodes` (the resolved target is `resolvedTarget`, the node a trigger-style `invokes` / `mentions` link points to, falling back to the raw `target` for path-style links; a genuinely-broken link whose target resolves to no node is excluded); `issues` carries those touching `nodes`. `truncated` is `total > cap`. Lets the SPA render a multi-folder selection without hydrating the full `ScanResult`. DB absent → empty branch (zero nodes). Validation: `limit` integer ≥ 1 else 400 `bad-query`. |
631
637
  | `GET /api/graph?format=ascii\|json\|md` | implemented | formatter-rendered graph. `Content-Type` per format: `text/plain` (ascii), `application/json` (json), `text/markdown` (md / mermaid). Default `format=ascii`. Unknown format → 400 `bad-query`. |
632
638
  | `GET /api/config` | implemented | `RestEnvelope` (`kind: 'config'`), merged effective config (defaults → user → user-local → project → project-local → override). |
633
- | `GET /api/plugins` | implemented | `RestEnvelope` (`kind: 'plugins'`), list of installed plugins (built-in + drop-in) with status. Item shape: `{ id, version, kinds, status, reason, source: 'built-in'\|'project', description?: string, locked?: boolean, startsAsDisabled?: boolean, extensions?: Array<{ id, kind, version, enabled, description?: string, stability?: 'experimental'\|'beta'\|'stable'\|'deprecated', locked?: boolean }> }`. The plugin row has no granular toggle axis; its `status` aggregates the children (`'enabled'` when at least one extension is enabled, else `'disabled'`). The `description` carries the manifest-declared description (built-ins: hardcoded on `IBuiltInPlugin`; drop-ins: `plugin.json#/description`); each `extensions[]` entry carries its manifest's `description` per `IExtensionBase` (`extensions/base.schema.json#/properties/description`), plus the optional `stability` lifecycle label per `extensions/base.schema.json#/properties/stability` (omitted when undeclared; missing means `stable`. The SPA badges only non-default values, `experimental` / `beta` / `deprecated`, next to the extension row; `stable` renders nothing. Presentation-only EXCEPT `experimental` and `deprecated`, which each flip the extension's installed default to disabled). The SPA's Settings list renders descriptions as muted secondary text and indexes them for substring search alongside the ids. The `extensions` array is present whenever the plugin declares any extension AND loaded successfully. Each entry's `enabled` reflects the per-extension override resolution (DB > settings.json > installed default, where the default is `false` for `experimental` and `deprecated` extensions and `true` otherwise). The optional `locked: true` flag is stamped when the plugin id (or qualified extension id) appears in the host's lock-list (`src/server/locked-plugins.ts`); locked items render the toggle disabled in the SPA and any `PATCH` returns `403 locked`. Omitted when false. The optional `startsAsDisabled: true` flag is stamped on drop-in plugins (never built-ins) whose discovery-time `status` was `'disabled'`, i.e. every extension was disabled in `config_plugins` / `settings.json` at `sm serve` boot, so the handlers were never bucketed into the runtime. The SPA renders a per-row hint when this flag is set AND the user re-enables at least one of the plugin's extensions in the buffered state, since re-enabling requires `sm serve` restart (the rest of the toggle pipeline applies live). Omitted when false. |
634
- | `PATCH /api/plugins/:id` | implemented | **Bundle (aggregate) macro endpoint**: fans the toggle out across every extension inside the plugin. `:id` MUST be a top-level plugin id (no slash); qualified-id form is the sibling route below. Body `{ enabled: boolean }` (JSON). Persists one `config_plugins` row per child extension (`<plugin>/<ext>`) in a single transaction; locked children are silently dropped, mirroring the CLI's bulk-mode lock semantics. Response is the canonical `RestEnvelope` (`kind: 'plugins'`) reflecting the post-write state. **Lock**, rejected with 403 `locked` when the plugin id itself is in the host lock-list (`src/server/locked-plugins.ts`). **Apply window**, the override applies on the next scan; both the BFF and the watcher build a fresh resolver from `config_plugins` before composing extensions, so the toggle is honoured without restarting `sm serve`. The endpoint purges `scan_contributions` rows for each disabled extension immediately so the UI stops rendering its chips before the next scan. **Exception**, drop-in plugins whose discovery-time `status` was `'disabled'` (carried as `startsAsDisabled: true`) are NOT in the runtime extension buckets; re-enabling them via PATCH persists the override but requires `sm serve` restart for the handlers to load. The SPA surfaces this per-row. The endpoint does NOT broadcast a WS event today. |
639
+ | `GET /api/plugins` | implemented | `RestEnvelope` (`kind: 'plugins'`), list of installed plugins (built-in + drop-in) with status. Item shape: `{ id, version, kinds, status, reason, source: 'built-in'\|'project', description?: string, locked?: boolean, startsAsDisabled?: boolean, trusted?: boolean, extensions?: Array<{ id, kind, version, enabled, description?: string, stability?: 'experimental'\|'beta'\|'stable'\|'deprecated', locked?: boolean }> }`. The plugin row has no granular toggle axis; its `status` aggregates the children (`'enabled'` when at least one extension is enabled, else `'disabled'`). The `description` carries the manifest-declared description (built-ins: hardcoded on `IBuiltInPlugin`; drop-ins: `plugin.json#/description`); each `extensions[]` entry carries its manifest's `description` per `IExtensionBase` (`extensions/base.schema.json#/properties/description`), plus the optional `stability` lifecycle label per `extensions/base.schema.json#/properties/stability` (omitted when undeclared; missing means `stable`. The SPA badges only non-default values, `experimental` / `beta` / `deprecated`, next to the extension row; `stable` renders nothing. Presentation-only EXCEPT `experimental` and `deprecated`, which each flip the extension's installed default to disabled). The SPA's Settings list renders descriptions as muted secondary text and indexes them for substring search alongside the ids. The `extensions` array is present whenever the plugin declares any extension AND loaded successfully. Each entry's `enabled` reflects the per-extension config resolution (`settings.local.json` over `settings.json` over installed default, where the default is `false` for `experimental` and `deprecated` extensions and `true` otherwise); enable no longer reads from the DB. The optional `locked: true` flag is stamped when the plugin id (or qualified extension id) appears in the host's lock-list (`src/server/locked-plugins.ts`); locked items render the toggle disabled in the SPA and any `PATCH` returns `403 locked`. Omitted when false. The optional `trusted: true` flag is stamped on a drop-in plugin that carries a local import-trust grant (a `config_plugins` trust row or the `pluginTrust.projectEnabled` opt-in); omitted when false, so an untrusted project-local plugin reads `trusted` absent (built-ins omit it, they are never trust-gated). The optional `startsAsDisabled: true` flag is stamped on a drop-in plugin (never built-ins) that was config-disabled at `sm serve` boot (discovery-time `status: 'disabled'` for a reason OTHER than untrust), so the handlers were never bucketed into the runtime; an untrusted plugin carries the one-time untrusted boot notice instead and does NOT get `startsAsDisabled`. The SPA renders a per-row hint when `startsAsDisabled` is set AND the user re-enables at least one of the plugin's extensions in the buffered state, since re-enabling requires `sm serve` restart (the rest of the toggle pipeline applies live). Omitted when false. |
640
+ | `PATCH /api/plugins/:id` | implemented | **Bundle (aggregate) macro endpoint**: fans the toggle out across every extension inside the plugin. `:id` MUST be a top-level plugin id (no slash); qualified-id form is the sibling route below. Body `{ enabled: boolean }` (JSON). Writes the per-extension `enabled` (`plugins.<id>.extensions.<ext>.enabled`) for every child to the config layers (the shared `settings.json`); locked children are silently dropped, mirroring the CLI's bulk-mode lock semantics. Response is the canonical `RestEnvelope` (`kind: 'plugins'`) reflecting the post-write state. **Lock**, rejected with 403 `locked` when the plugin id itself is in the host lock-list (`src/server/locked-plugins.ts`). **Apply window**, the override applies on the next scan; both the BFF and the watcher build a fresh resolver from the config layers before composing extensions, so the toggle is honoured without restarting `sm serve`. The endpoint purges `scan_contributions` rows for each disabled extension immediately so the UI stops rendering its chips before the next scan. **Exception**, drop-in plugins whose discovery-time `status` was `'disabled'` (carried as `startsAsDisabled: true`) are NOT in the runtime extension buckets; re-enabling them via PATCH persists the override but requires `sm serve` restart for the handlers to load. The SPA surfaces this per-row. The endpoint does NOT broadcast a WS event today. |
635
641
  | `PATCH /api/plugins/:pluginId/extensions/:extensionId` | implemented | Canonical per-extension toggle. Body `{ enabled: boolean }`. Both segments are URL-path-segment-encoded (no slash inside `:pluginId` or `:extensionId`). 404 `not-found` when the plugin id is unknown or the extension id does not belong to that plugin. **Lock**, rejected with 403 `locked` when either the plugin id or the qualified `pluginId/extensionId` appears in the host lock-list. Same persistence + apply-window semantics as the bundle macro form (including the `startsAsDisabled` exception). The SPA's buffered Settings modal posts here for every per-row flip. |
636
- | `PATCH /api/plugins` | implemented | Bulk toggle. Body `{ "changes": Array<{ "id": string, "enabled": boolean }> }` where each `id` is either a bare plugin id (bundle cascade macro, same semantics as `PATCH /api/plugins/:id`) or a qualified `<plugin>/<extension>` id. Empty `changes` is a no-op (returns the current `GET /api/plugins` envelope). The route validates the **entire batch** before writing; any invalid entry (`unknown-plugin` / `locked`) rejects the whole request with the offending id in `error.details.id`, DB untouched. Valid batches apply in **one SQLite transaction**: bare plugin ids expand to child qualified ids before persistence; `IConfigPluginsPort.set` is called per resulting key, then one grouped `scan_contributions` purge per disabled extension (enables skip the purge). Response is the same `RestEnvelope` (`kind: 'plugins'`) shape as `GET /api/plugins`, reflecting the post-write state; the SPA replaces its modal state from it. Apply-window and `startsAsDisabled` exception semantics match the per-id routes. The single-id `PATCH /api/plugins/:id` and qualified-id sibling stay available for CLI / external automation; the bulk variant lets the SPA stage edits in a buffered modal and ship the final delta atomically. |
642
+ | `PATCH /api/plugins` | implemented | Bulk toggle. Body `{ "changes": Array<{ "id": string, "enabled": boolean }> }` where each `id` is either a bare plugin id (bundle cascade macro, same semantics as `PATCH /api/plugins/:id`) or a qualified `<plugin>/<extension>` id. Empty `changes` is a no-op (returns the current `GET /api/plugins` envelope). The route validates the **entire batch** before writing; any invalid entry (`unknown-plugin` / `locked`) rejects the whole request with the offending id in `error.details.id`, DB untouched. Valid batches apply in **one SQLite transaction**: bare plugin ids expand to child qualified ids before persistence; the per-extension `enabled` is written to the config layers per resulting key, then one grouped `scan_contributions` purge per disabled extension (enables skip the purge). Response is the same `RestEnvelope` (`kind: 'plugins'`) shape as `GET /api/plugins`, reflecting the post-write state; the SPA replaces its modal state from it. Apply-window and `startsAsDisabled` exception semantics match the per-id routes. The single-id `PATCH /api/plugins/:id` and qualified-id sibling stay available for CLI / external automation; the bulk variant lets the SPA stage edits in a buffered modal and ship the final delta atomically. |
643
+ | `PATCH /api/plugins/:id/trust` | implemented | Plugin-level LOCAL import-trust toggle (the security axis, separate from enable). `:id` MUST be a bare plugin id (no slash). Body `{ trusted: boolean }` (JSON). Writes (true) or clears (false) the plugin's row in the `config_plugins` (DB) trust store. Built-ins and locked ids are rejected with 403 `locked` (they are never trust-gated). Response is the canonical `RestEnvelope` (`kind: 'plugins'`) reflecting the post-write `trusted` projection. Granting trust lets an enabled plugin's code import on the next scan / `sm serve` restart (handlers load on restart, like the `startsAsDisabled` case); revoking reverts the plugin to discovered-but-unexecuted. Does NOT touch the enable axis. The local opt-in `pluginTrust.projectEnabled` (a project-local-only config key, surface-expanding, gated by the `confirm-required` flow) is the bulk alternative, set through the project-preferences route, not here. |
637
644
  | `ALL /api/*` (other) | reserved | structured 404 envelope (see below); future endpoints land in subsequent sub-steps. |
638
645
  | `GET /ws` | implemented (v14.4.a) | accepts WebSocket upgrade and registers the client with the BFF broadcaster. Server-push only, the server fans `scan.*` (and forthcoming `issue.*`) events to every connected client. See **WebSocket protocol** below. |
639
646
  | `GET *` | implemented | static asset from the resolved UI bundle, falling back to `index.html` for SPA deep links. |
package/db-schema.md CHANGED
@@ -431,22 +431,22 @@ The BFF's `/api/nodes` route loads the full set of favorited paths once per requ
431
431
 
432
432
  ### `config_plugins`
433
433
 
434
- Persists user-toggled enable/disable overrides. Discovery is still filesystem-based; this table records user intent.
434
+ Records the operator's LOCAL import-trust grants for project-local drop-in plugins. This is the **security** axis (may THIS machine import and run the plugin's code), NOT the operational enable/disable toggle, which lives in the config layers (`plugins.<id>.enabled` / `plugins.<id>.extensions.<ext>.enabled` in `settings.json` / `settings.local.json`). Discovery is still filesystem-based; this table records per-machine consent. The table name is retained for continuity.
435
435
 
436
436
  | Column | Type | Constraint |
437
437
  |---|---|---|
438
438
  | `plugin_id` | TEXT | PRIMARY KEY |
439
- | `enabled` | INTEGER | NOT NULL DEFAULT 1 |
440
- | `config_json` | TEXT | NULL |
439
+ | `trusted` | INTEGER | NOT NULL DEFAULT 0, CHECK (`trusted` IN (0,1)) |
441
440
  | `updated_at` | INTEGER | NOT NULL |
442
441
 
443
- **Effective enable/disable resolution.** A plugin is enabled iff the highest-precedence layer that mentions it says so. Order from highest to lowest:
442
+ **Effective trust resolution.** A project-local plugin's code is imported iff it is **enabled** (config layers) AND it is **trusted** locally. A plugin is trusted iff either:
444
443
 
445
- 1. `config_plugins.enabled` for the row whose `plugin_id` matches, written by `sm plugins enable/disable`. Local-machine user override; never committed (the DB is gitignored unless the team removes the `.gitignore` entry).
446
- 2. `.skill-map/settings.json#/plugins/<id>/enabled`, committed team-shared baseline.
447
- 3. Installed default, every discovered plugin is enabled until told otherwise.
444
+ 1. A `config_plugins` row with `trusted = 1` exists for its `plugin_id`, written by `sm plugins trust` (cleared by `sm plugins untrust`). Keyed by the **bare plugin id** (trust is per-plugin; a qualified `<plugin>/<ext>` collapses to its plugin), OR
445
+ 2. the local opt-in `pluginTrust.projectEnabled` (project-local-only config, honoured only from `settings.local.json`) is set, which trusts every plugin the project enables.
448
446
 
449
- The DB takes precedence over `settings.json` so a developer can locally disable a misbehaving plugin without committing the toggle to the team's config. Conversely, a team baseline that explicitly enables a plugin is overridable per-machine, no agreement required to experiment.
447
+ The store is structurally LOCAL: the DB never travels in a commit and is not a config layer, so a cloned repo's committed `settings.json` can never grant import trust to its own plugins (the supply-chain guard). Built-ins and `--plugin-dir` are not trust-gated. See [`architecture.md` §Locality](./architecture.md) (plugin enable vs import trust).
448
+
449
+ Greenfield note: this table previously stored the enable/disable toggle (`enabled` plus a vestigial `config_json`); both were dropped when enable moved to the config layers. Per the pre-1.0 greenfield posture the redefinition is applied inline to `001_initial.sql` with no migration file; the `scan_meta.schema_fingerprint` drift path rebuilds the cache on the first scan.
450
450
 
451
451
  ### `config_preferences`
452
452
 
package/index.json CHANGED
@@ -174,14 +174,14 @@
174
174
  }
175
175
  ]
176
176
  },
177
- "specPackageVersion": "0.62.1",
177
+ "specPackageVersion": "0.63.0",
178
178
  "integrity": {
179
179
  "algorithm": "sha256",
180
180
  "files": {
181
- "CHANGELOG.md": "94c7b5d10677512dcd3815fcf6971821a656ac9d55e76bc150a7e5ddcd0375f9",
181
+ "CHANGELOG.md": "3b206cc04092e6a60b720983546117e6a76ef564ae4cee9672d3428e58048d6e",
182
182
  "README.md": "a790cd010b46d47883d1f37e3893cea9d7aa69ec4750c0202e6a0c99991e7980",
183
- "architecture.md": "434954b096c0f34752002996356d2d8e7cf578b345f62525fe80a50a72ccb6ba",
184
- "cli-contract.md": "630ecf6598a17e5df941c3ad630cbec519ef5e6e57207a81996ef881aec4e6be",
183
+ "architecture.md": "f536b1a660958f701fd356858ca352a0929ac7454c6d59de71644f3693d9f21a",
184
+ "cli-contract.md": "07a1b15ec07a9bbcbe9d1eccba3ca7234043e52bbb584c25470f66d571e6b2ae",
185
185
  "conformance/README.md": "dcbef7249f161acf597552a05dcadc813cd0ced430dcd3f813fcf5e1c876335d",
186
186
  "conformance/cases/backtick-path-extraction.json": "4620e7f8bc161fc57cb44001e9d99879c7e22b4865a0c27a20dc28969cd936d9",
187
187
  "conformance/cases/extractor-collision-detection.json": "179a02c61892f0d26492de0c4e2c327fa6b4986d1265a8f119e871df6afe4658",
@@ -230,11 +230,11 @@
230
230
  "conformance/fixtures/view-contribution-payloads/notes/example.md": "312b1919cd7fd0f233648b053acfb2975662ede3c65dd391cc508204b67ad6fb",
231
231
  "conformance/fixtures/view-slots-all/.skill-map/plugins/all-slots/analyzers/everything/index.js": "ea0022fec7f0fd5a26ba12db1310335f434f2f820682206a3a9542d98db0d346",
232
232
  "conformance/fixtures/view-slots-all/.skill-map/plugins/all-slots/plugin.json": "c48e8a0574947ade0b4eb189d6bc27a48e24f92f616aacdc177f2d22d472a599",
233
- "db-schema.md": "4c0f4df57f98b0b91a97382a4570f6178179a2f5282744a1d39d3b123ad18445",
233
+ "db-schema.md": "05a4e323877d391180201f08eca9a226bd70f6ca593e1052602c54827685ef70",
234
234
  "interfaces/security-scanner.md": "0996dd782e2d39d4791f2e290da4bb1a68a5b30c1f79187977188ec8e3fe6ef2",
235
235
  "job-events.md": "2c7017f5f0003b19653424111a07043487173cbe88b51e961598bb1693987059",
236
236
  "job-lifecycle.md": "ce33bc8bb5090ea183f860e495bfccc2a4a0ac2e23f6ebad83b9c28aad59124e",
237
- "plugin-author-guide.md": "9244ee6aef49100867d46b324e13b3c3413a071813e34889563d4bfcef78c7f0",
237
+ "plugin-author-guide.md": "1b5980a76621c9aba7587b213d648e33f4a6c428ac8b40a5ee6e2378229c3fa4",
238
238
  "plugin-kv-api.md": "5e095581020043af73ff028e272f56d42ca9eb6e506dd777d45703f9db796a5b",
239
239
  "plugin-quickstart.md": "19092b278d80df357ea623dc3bd9f833d059582ee1356f317621913d91e50512",
240
240
  "prompt-preamble.md": "5d0f836688aa23eafc32104c3174132340b268361f6060326eec84da17c6ad6d",
@@ -261,7 +261,7 @@
261
261
  "schemas/node.schema.json": "1ebba38e0c0ae022fccbc0cdf7c298da1720a68d4cb375f0baf9f0847998a0d8",
262
262
  "schemas/plugins-doctor.schema.json": "03e2dc51c052a09bf0198c80e2c26e6129734ada4a748e483245de3dd8576c42",
263
263
  "schemas/plugins-registry.schema.json": "211d081691fc83526e1593c79ed9741ad8a5dbd4db1a756f72141b0cced2ea15",
264
- "schemas/project-config.schema.json": "67e4d83d69b336bc2017cc438ba6d562445fd97061e7e543a16d4e951b7047fc",
264
+ "schemas/project-config.schema.json": "6b654c0aa5ad4cd950166804327b41059f0c5a1d84e1b62ee1015c22a1c100ba",
265
265
  "schemas/refresh-report.schema.json": "47184d4f6b15e9b7671dc178b3b3886a64422da198898508ecdb2cb27876db04",
266
266
  "schemas/report-base-deterministic.schema.json": "59785fe6f3ceb34814bbbd03d10fa7336a32835ce598946f2923d469b32aa32a",
267
267
  "schemas/report-base.schema.json": "e4d25f055e24f18ae0f77c24661c1bddc87ff2e43b001b6a827fcb14f9753f44",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-map/spec",
3
- "version": "0.62.1",
3
+ "version": "0.63.0",
4
4
  "description": "JSON Schemas, prose contracts, and conformance suite for the skill-map specification.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -60,7 +60,7 @@ The kernel scans one root: `<cwd>/.skill-map/plugins/`, committed-with-the-repo
60
60
 
61
61
  A plugin is any direct child directory of that root containing a `plugin.json`. Nested directories are not searched recursively. Pass `--plugin-dir <path>` to replace the default root with a custom directory (mostly for testing, or a plugin set the operator opts into).
62
62
 
63
- **Import trust.** A project-local plugin is discovered (manifest parsed, listed by `sm plugins list`) but its code is NOT imported by the runtime verbs (`sm scan`, `sm serve`, ...) until the operator trusts it locally with `sm plugins enable <id>` (or the Settings toggle), which writes a `config_plugins` override. This is a security boundary: cloning a repo and scanning it must not auto-execute the repo's plugins, so the committed `settings.json` baseline cannot grant import trust, only the local DB override can. Authors developing a plugin enable it once locally; `--plugin-dir` is not gated. See [`architecture.md` §Locality](./architecture.md).
63
+ **Import trust.** A project-local plugin is discovered (manifest parsed, listed by `sm plugins list`) but its code is NOT imported by the runtime verbs (`sm scan`, `sm serve`, ...) until the operator trusts it locally with `sm plugins trust <id>` (or the per-plugin Trust control in the Settings UI), which writes a per-plugin row in the `config_plugins` (DB) trust store. This is a security boundary, and a SEPARATE axis from enable/disable (the operational toggle, which lives in the config layers): a plugin runs only when it is both enabled and trusted. Cloning a repo and scanning it must not auto-execute the repo's plugins, so the committed `settings.json` baseline cannot grant import trust, only the local DB trust store (or the local opt-in `pluginTrust.projectEnabled`) can. Authors developing a plugin trust it once locally; `--plugin-dir` is not gated. See [`architecture.md` §Locality](./architecture.md).
64
64
 
65
65
  After every change to `plugins/`, run `sm plugins list` to see each plugin's load status. The seven statuses are documented under [Diagnostics](#diagnostics).
66
66
 
@@ -115,7 +115,7 @@ Two id shapes resolve at the toggle surface:
115
115
 
116
116
  `--all` is the cascade variant: expands to every extension in every discovered plugin under the same `--yes` / TTY-confirm gate.
117
117
 
118
- Resolution order per id: DB override (`config_plugins`) > `settings.json#/plugins/<id>/enabled` > installed default. The installed default is `true` for ordinary extensions and `false` for extensions declaring `stability: 'experimental'` or `stability: 'deprecated'` (they ship disabled until the operator opts in; see [Extension manifests](#extension-manifests)). Persisted toggle keys are always qualified `<plugin>/<ext>` ids (the bundle macro expands at write time).
118
+ Resolution order per id (the operational ENABLE axis): per-extension config (`plugins.<id>.extensions.<ext>.enabled`) > plugin-level config (`plugins.<id>.enabled`) > installed default, resolved through the config layers (`settings.local.json` over `settings.json`). The installed default is `true` for ordinary extensions and `false` for extensions declaring `stability: 'experimental'` or `stability: 'deprecated'` (they ship disabled until the operator opts in; see [Extension manifests](#extension-manifests)). Enable no longer reads from the DB; the `config_plugins` table now holds only the per-plugin import-trust grant (the security axis, see Import trust above). Persisted enable keys are written per qualified `<plugin>/<ext>` (the bundle macro expands at write time).
119
119
 
120
120
  ### Extractor / Analyzer / Action `precondition`, narrow the pipeline
121
121
 
@@ -214,7 +214,7 @@ The kernel knows six categories. Each has a JSON Schema under [`schemas/extensio
214
214
 
215
215
  The runtime instance you `export default` includes both the manifest fields (`version`, `description`, plus kind-specific metadata) AND the runtime method. The kernel strips function-typed properties before AJV-validating the manifest, so the method lives beside metadata.
216
216
 
217
- Base manifest fields shared by every kind (normative shape in [`schemas/extensions/base.schema.json`](./schemas/extensions/base.schema.json)): `version` (required for external plugins), `description` (required), and the optional `stability`, `order`, `annotation`, `settings`. `stability` (`'experimental' | 'beta' | 'stable' | 'deprecated'`, default `stable`) is a lifecycle label: the non-default values render as a badge next to the extension in `sm plugins list <id>` / `sm plugins show` and the Settings plugins panel. Presentation-only for `beta` and `stable`, but `experimental` and `deprecated` additionally flip the extension's installed default to DISABLED: it does not load (does not run, does not register, toggle shows off) until the operator opts in via `sm plugins enable <plugin>/<ext>`, the Settings toggle, or a `settings.json` / `config_plugins` override. The opt-in wins over the installed default, so a `deprecated` extension can be kept running during a migration. A stable extension omits the field; declaring `stability: 'stable'` is valid but renders nothing.
217
+ Base manifest fields shared by every kind (normative shape in [`schemas/extensions/base.schema.json`](./schemas/extensions/base.schema.json)): `version` (required for external plugins), `description` (required), and the optional `stability`, `order`, `annotation`, `settings`. `stability` (`'experimental' | 'beta' | 'stable' | 'deprecated'`, default `stable`) is a lifecycle label: the non-default values render as a badge next to the extension in `sm plugins list <id>` / `sm plugins show` and the Settings plugins panel. Presentation-only for `beta` and `stable`, but `experimental` and `deprecated` additionally flip the extension's installed default to DISABLED: it does not load (does not run, does not register, toggle shows off) until the operator opts in via `sm plugins enable <plugin>/<ext>`, the Settings toggle, or a `settings.json` / `settings.local.json` enable override. The opt-in wins over the installed default, so a `deprecated` extension can be kept running during a migration. A stable extension omits the field; declaring `stability: 'stable'` is valid but renders nothing.
218
218
 
219
219
  ### Extractors
220
220
 
@@ -85,11 +85,15 @@
85
85
  "enabled": { "type": "boolean" },
86
86
  "extensions": {
87
87
  "type": "object",
88
- "description": "Per-extension overrides, keyed by extension id (the leaf folder name, NOT the qualified `<plugin>/<ext>` id, the plugin is already the parent key). Today only `settings`; a future per-extension `enabled` filter (the deferred Phase 4+ work) would slot in alongside it.",
88
+ "description": "Per-extension overrides, keyed by extension id (the leaf folder name, NOT the qualified `<plugin>/<ext>` id, the plugin is already the parent key). Carries the per-extension `enabled` toggle (operational) and the operator-supplied `settings` values.",
89
89
  "additionalProperties": {
90
90
  "type": "object",
91
91
  "additionalProperties": false,
92
92
  "properties": {
93
+ "enabled": {
94
+ "type": "boolean",
95
+ "description": "Per-extension operational on/off, resolved over the plugin-level `enabled` and the extension's installed default (`false` for `experimental` / `deprecated`, `true` otherwise). Shareable: lands in `settings.json` (team baseline) or `settings.local.json` (per-checkout override) via the normal config layering. This is the OPERATIONAL axis only; it does NOT grant import trust for a project-local plugin (see the top-level `pluginTrust`)."
96
+ },
93
97
  "settings": {
94
98
  "type": "object",
95
99
  "description": "Operator-supplied values for the extension's declared settings, keyed by settingId. Values are intentionally NOT validated by this schema: the kernel's settings resolver validates each value against the per-type value schema of the input-type the manifest declares (`input-types.schema.json#/$defs/ISettingDeclaration`), since this schema cannot know which type a given settingId picked. A non-`secret` setting lands here in `settings.json` (team-shared) or `settings.local.json` (per-checkout override) via the normal config layering; `secret` settings are NOT stored here, they ride the dedicated encrypted `state_secrets` path (see `input-types.schema.json#/$defs/Setting_Secret`).",
@@ -101,6 +105,17 @@
101
105
  }
102
106
  }
103
107
  },
108
+ "pluginTrust": {
109
+ "type": "object",
110
+ "additionalProperties": false,
111
+ "description": "Local, per-machine plugin import-trust preferences. NOT part of the shareable enable/disable axis. Project-local ONLY: the config loader strips `pluginTrust` from the committed `project` layer (it is honoured only from `settings.local.json`), so a cloned repo can never auto-grant import trust to its own project-local plugins. Setting `projectEnabled: true` expands the local execution surface and is gated behind a confirm (CLI `--yes`, UI confirm dialog), like `scan.referencePaths`.",
112
+ "properties": {
113
+ "projectEnabled": {
114
+ "type": "boolean",
115
+ "description": "When true, every plugin this project ENABLES is treated as locally trusted, so its code may be imported without an explicit per-plugin trust grant in the `config_plugins` (DB) trust store. Default false. The operator's local opt-in for teams that vet plugins in code review. Project-local only (stripped from the committed `project` layer)."
116
+ }
117
+ }
118
+ },
104
119
  "jobs": {
105
120
  "type": "object",
106
121
  "additionalProperties": false,