Skip to content

Make plugin signature enforcement opt-in - #2212

Merged
jmecom merged 5 commits into
mainfrom
jm/enforce-plugin-signatures
Nov 11, 2026
Merged

jmecom merged 5 commits into
mainfrom
jm/enforce-plugin-signatures

Conversation

@jmecom

@jmecom jmecom commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Make external plugin signature enforcement opt-in with BUZZ_REQUIRE_PLUGIN_SIGNATURES=1. Unsigned plugins continue to install and run by default. Supplied signatures are always verified, and publisher and downgrade protections remain in place.

Settings can apply a newly signed release even when its code has not changed.

Validation: 143 plugin-manager Rust tests, two focused CLI tests, and 36 plugin import/reload browser checks across Chromium and WebKit passed. Required pre-push checks also passed. Packaged-app and human testing remain pending.

@jmecom
jmecom marked this pull request as ready for review November 3, 2026 23:02
@jmecom
jmecom requested a review from a team as a code owner November 3, 2026 23:02
@jmecom jmecom changed the title Require signatures for external plugins Require signatures for external plugins by default Nov 4, 2026

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Star Lord’s automated source review via Wes’s account, integrating independent Rocket, Mantis and Groot coverage.

One actionable P2 is inline. No additional actionable findings in the signature verifier, publisher continuity, rollback restrictions, native access checks or contribution teardown. The documented any-publisher trust policy and exact 0 opt-out are intentional; signing does not sandbox plugin code.

Reviewed head: 5aec92d
Base: 063fccd

Source-only: tests, builds, CLI/app workflows, keyboard/focus behavior and packaged-app migration were not executed; CI was not assessed. The public PR description and changed source/commit text were inspected; no description attachments. This is a COMMENT review, not approval or merge authorization.

fn verified_publisher(digest: &str, signature: Option<&Event>) -> Result<Option<String>> {
match signature {
Some(signature) => publisher_for_hash(digest, signature).map(Some),
None if signatures_required() => Err(UNSIGNED_PLUGIN_ERROR.into()),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [P2] Let Settings apply a signature-only repair before blocking legacy plugins.

This new gate disables existing unsigned installs. If an author signs the unchanged installed source, sign_release serializes the same artifact bytes as the unsigned import (imports.rs:418–436), so its revision hash stays the same. Native install_artifact can persist the new signature without changing that hash (the new unsigned_legacy_installation_can_be_replaced_with_a_signed_release test covers that layer). But src/app/PluginImport.tsx:361–378 compares only revisions for upToDate/updateAvailable, and lines 944–949 hide confirmation for a matching revision. The folder update check also returns only the hash (imports.rs:299–307). Settings therefore says “Up to date” and offers no repair while the plugin remains blocked for a missing signature; generic import redirects to the existing install. CLI reinstall or removal/reinstallation is a workaround.

Make update discovery and confirmation recognize a newly valid signature on an unsigned installed revision, while retaining normal publisher verification. Add a UI-level regression covering an enabled unsigned install → signed candidate with identical artifact hash → confirmed update → cleared error/activation. Changing only the native reinstall test or the dialog predicate would leave the hash-only discovery path unresolved.

@jmecom
jmecom marked this pull request as draft November 5, 2026 19:38
@jmecom
jmecom requested a review from wesbillman November 5, 2026 20:19
@jmecom
jmecom marked this pull request as ready for review November 5, 2026 20:19
@jmecom
jmecom force-pushed the jm/enforce-plugin-signatures branch from 947b2e7 to ab867b0 Compare November 5, 2026 20:25

@shellz-n-stuff shellz-n-stuff left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, we should get this one in sooner as it's a core building block for later stages with hierarchal enforcement and policies

@jmecom
jmecom marked this pull request as draft November 6, 2026 17:22
@jmecom
jmecom force-pushed the jm/enforce-plugin-signatures branch from ab867b0 to 7484be9 Compare November 6, 2026 17:25
@jmecom jmecom changed the title Require signatures for external plugins by default Make plugin signature enforcement opt-in Nov 6, 2026
@jmecom
jmecom force-pushed the jm/enforce-plugin-signatures branch from 7484be9 to c111df1 Compare November 9, 2026 18:37

@fsola-sq fsola-sq left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed c111df1 against 3b98f2b. Assessment: 9/10 for the scoped implementation. No recommended blockers.

The Rust manager remains the enforcement owner. I traced import, catalog, enable/load, native host grants, reload, publisher continuity, rollback, and runtime disposal. The unchanged-artifact repair now reaches Settings through both folder and Git discovery, and pending update notices account for publisher changes. The prior signature-only repair finding is addressed. The UI keeps the existing shared Dialog, Button, SettingsGroup, and access-review owners; no new design exception is needed.

Optional documentation improvement: explicitly state that URL-based recommendations and artifact links are unavailable when BUZZ_REQUIRE_PLUGIN_SIGNATURES=1. prepare_url constructs a release without a signature (crates/plugin-manager/src/imports.rs:643-665), and the version-1 URL entry has no detached-signature field. Even packing an already signed folder produces an unsigned artifact. This is consistent with requiring signatures, but the adjacent URL-distribution instructions can lead an operator to expect that path to work in strict mode. A sentence in docs/managed-plugins.md#url-entries and the URL paragraph in docs/plugin-architecture.md is sufficient; adding a new release format is outside this PR.

Validation on the reviewed head: bin/cargo test -p buzzodz-plugins --locked passed; the six changed plugin/Settings Vitest files passed (269 tests); bin/node --test tests/integration/plugin-cli.test.mjs passed (4 tests); the complete plugin-import, plugin-cli-reload, and conversation browser files passed in Chromium and WebKit (36 tests, 2.3 minutes). The browser run exercised real CLI/native-catalog replacement and withdrawal, the existing Settings layout/scale checks, focus, error recovery, and both themes. I inspected the generated import/update screenshots. git diff --check passed, and the review worktree is clean.

Hosted checks have no reported failures, but six jobs were still running when checked, including Rust/tool integration and browser shards. The PR remains draft. Packaged desktop, human acceptance, and release-specific launch-environment behavior were not exercised.

@jmecom
jmecom marked this pull request as ready for review November 9, 2026 18:50
kazemek21 added a commit that referenced this pull request Nov 9, 2026
… an update (#3024)

## TL;DR

A `buzz://plugin-install` Git link pinned to both a `commit` and a
`path`, for a plugin already installed from that same repository and
folder, now opens the **Update** review that a plugin's Git
`requestInstall` already gets. If the link names another branch or tag
than the plugin tracks, the review says so. Before, it hit the Add
path's **Already installed** refusal, so there was no reviewed way to
update from such a link. Links without both pins, and links to a plugin
installed from another source, are still refused as duplicates.

## What changed

- When the installed plugin was imported (not bundled), `PluginImport`
treats a link with both `commit` and `path` like a bound
`requestInstall` request when deciding between update and duplicate
(`src/app/PluginImport.tsx`). It reuses the existing same-source check:
the installed `updateSource` must be a Git source whose repository
matches the fetched preview's source and whose folder matches the
candidate's folder, and the candidate must have the installed manifest
ID. Native import saves the requested `ref`, and later update checks
follow it. So when a link's or plugin request's `ref` differs from the
installed Git source's ref, the review adds "Future updates will follow
<new> instead of <old>." An empty ref reads as "the repository default".
- Confirming a requested update (link or plugin request) re-reads the
live catalog and refuses with "The installed plugin changed. Close this
review and check again." if the installed revision, enabled state or
saved source changed after the review opened, so the review's "stays
disabled" line can't go stale.
- A requested update of a disabled plugin now says "This plugin will
stay disabled.", as the Details **Update** review already does. Native
install already keeps the enabled state, and the review offers no enable
switch for updates.
- Docs: `docs/plugin-architecture.md`, `docs/deep-links.md` and
`docs/buzz-help.md` describe the pinned-link update.

Protections that still apply: the commit pin (native refuses a ref whose
tip differs, and the review re-checks the fetched commit), the folder
filter, the access review against the installed manifest, the publisher
line and native publisher-continuity refusal, the unsigned-to-signed
rollback warning, explicit **Update** confirmation, and preview discard
on cancel. Links with only `commit`, only `path`, or neither, and pinned
links to a bundled plugin, keep showing **Already installed** / **Open
in Buzz**.

Not in scope: update discovery, Update-all, managed policy feeds or
checkers (#2504, #2621, #637), signature or publisher-trust policy
(#2212, #2746), and the compact request dialog (#2625). This PR doesn't
touch their hunks. #2983 and #2762 also edit
`tests/browser/plugin-import.spec.mjs`; this PR leaves that file
unchanged.

## Supported operation

On a Buzz desktop build that includes this change, open
`buzz://plugin-install?repository=<repo>&ref=<ref>&path=<folder>&commit=<40-or-64-hex>`
for a plugin whose installed copy came from a Git import of that same
repository and folder. The review shows **Update <name>**, with access
changes listed against the installed version, and states any change to
the branch or tag later updates follow. Nothing changes until you choose
**Update**. Native import still refuses the update if the ref's tip
isn't the pinned commit, or if the publisher changed or a signed plugin
became unsigned.

## Tests

New `src/app/PluginImport.test.tsx` cases, all with fictional
`example.com` fixtures:

- A pinned link to the installed source reviews as **Update**, installs
the pinned folder, and doesn't call enable. This is the regression test
for the original failure.
- A disabled plugin stays disabled and the review says so.
- A pinned link with another ref than the installed plugin tracks (a
named ref, or the repository default) reviews as **Update**, says which
ref future updates will follow, and installs on **Update**. A same-ref
link shows no such line.
- A link without both pins (none, path only, commit only), or a pinned
link to a bundled plugin, still shows **Already installed** with **Open
in Buzz**.
- An installed source in another repository or folder, a local folder,
or an unknown source is refused with the source-mismatch message and
**Open in Buzz**.
- When only another plugin ID is installed from that source, the link
offers a new install.
- A fetched commit that differs from the pin offers no **Update**.
- Cancel discards the preview without installing.
- If the installed revision, enabled state or source changes after the
review opens, **Update** is refused, for both a link and a plugin's
`requestInstall`.

The request and link harnesses now return a ready catalog from
`plugins.snapshot()`, which the new confirm-time check reads.

Checked on a Linux box at `37cf73e1`: `biome check` on both files, `pnpm
typecheck`, and `vitest run src/app/PluginImport.test.tsx` (100 passed).
After the first review fixes, the same three checks passed locally (102
tests), and the bundled-plugin test fails on the earlier predicate. The
later enabled-state drift case (two lines plus one test input) passed
biome locally; host load refused a local test run, so PR CI covers it.
With only the `requested` line reverted, 6 of the 8 `pinned link` tests
fail. Against `main`'s `PluginImport.tsx`, the same 6 fail and the other
94 pass. The two that pass either way are the existing pin-retry test
and the other-plugin-ID new-install case, which should behave the same
before and after.

After review feedback, the tracked-ref disclosure was verified on a Blox
box at `e913727f`: `biome check`, `pnpm typecheck` and `vitest run
src/app/PluginImport.test.tsx` (103 passed). With the review line
disabled, the new tracked-ref test fails. The before/after screenshots
predate this line and show a same-ref update.

### Before / after

These screenshots come from a temporary Chromium journey using the
existing `nativeImports` IPC fixture and fictional `example` data; the
journey is not committed. It installs `example.linked` from
`https://github.com/example/plugins` at `plugins/linked`, then delivers
the same pinned link again with a newer candidate.

| | Light | Dark |
|---|---|---|
| Before (`main`) |
![t027-before-light](https://d24qwcpro867f5.cloudfront.net/repos/block/buzz-app/prs/3024/t027-before-light.png)
|
![t027-before-dark](https://d24qwcpro867f5.cloudfront.net/repos/block/buzz-app/prs/3024/t027-before-dark.png)
|
| After |
![t027-after-light](https://d24qwcpro867f5.cloudfront.net/repos/block/buzz-app/prs/3024/t027-after-light.png)
|
![t027-after-dark](https://d24qwcpro867f5.cloudfront.net/repos/block/buzz-app/prs/3024/t027-after-dark.png)
|

## Remaining gaps

- No native (Tauri) run: the Playwright screenshots use the existing IPC
fixture. Native import, publisher continuity and the deep-link handoff
were not changed here.
- No human has tried it in the app yet, so `buzz-review-completed` is
not set.

## Initiative context

Status: https://g2.sqprod.co/apps/kylez-buzz-improvements-status
(buzz-improvements T027).

Fixes NEXUS-4327

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Kyle Zemek <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
jmecom and others added 5 commits November 11, 2026 08:50
Co-authored-by: Codex <[email protected]>
Signed-off-by: Jordan Mecom <[email protected]>
Keep verified publisher metadata through update discovery and confirmation. Retire pending updates when the installed release changes.

Co-authored-by: Codex <[email protected]>
Signed-off-by: Jordan Mecom <[email protected]>
Allow unsigned external plugins by default. Require signatures only when BUZZ_REQUIRE_PLUGIN_SIGNATURES=1, retaining verification and publisher continuity for signed releases.

Co-authored-by: Codex <[email protected]>
Signed-off-by: Jordan Mecom <[email protected]>
@jmecom
jmecom force-pushed the jm/enforce-plugin-signatures branch from 4850e87 to 9793c15 Compare November 11, 2026 16:54
@jmecom
jmecom merged commit 0b23e10 into main Nov 11, 2026
70 checks passed
@jmecom
jmecom deleted the jm/enforce-plugin-signatures branch November 11, 2026 17:18
bostonaholic added a commit that referenced this pull request Nov 11, 2026
…y-untagged-reactions

* origin/main: (324 commits)
  fix(github): show private-repo body images from signed links (#3252)
  NEXUS-4029: Preserve semantic chips in the core composer (#2303)
  Make plugin signature enforcement opt-in (#2212)
  NEXUS-4156: Add workspace Leads and choose a chat's workspace (#2788)
  feat(plugins): report conclusively retired update providers (#3263)
  feat(messages): let attachment renderers open files from the host file card (#2239)
  feat(charts): bundle a Charts code-block renderer (#3226)
  Expose native reply grouping through Conversation.ui (#3245)
  NEXUS-4163: Keep plugin-owned chat deletion in Solo (#2787)
  NEXUS-4156: Expose Solo Workspaces to plugin regions (#2786)
  NEXUS-4022: Start region chats in the New Chat editor (#2784)
  feat(link-previews): unfurl web links below messages by default (#3225)
  fix(messages): keep names in literal Markdown as written when editing (#2834)
  feat(plugins): fetch a packaged managed plugin policy from a URL (#2504)
  fix(goose): enable deferred tool loading for goose agents (#3227)
  fix(unread): preserve manual intent in captured explicit reads (#3228)
  NEXUS-4337: Expose retained plugin update reviews (#3075)
  feat(conversation): add a link preview slot below messages (#3219)
  feat(plugins): let link previews read a public page's preview fields (#3217)
  Let sidebar row plugins decorate ordinary Solo chats (#3222)
  ...
tlongwell-block added a commit that referenced this pull request Nov 11, 2026
* origin/main: (121 commits)
  Expose Solo region state before a component mounts (#3296)
  fix(builderlab): say a 401 was refused, not expired, and show each sign-in failure once (#3241)
  feat(agents): file new Solo chats in explicit personal workspaces (#2857)
  perf(css): limit broad style invalidation (#3267)
  Delay and fade in the Bestie badge celebration (#3292)
  fix(github): show private-repo body images from signed links (#3252)
  NEXUS-4029: Preserve semantic chips in the core composer (#2303)
  Make plugin signature enforcement opt-in (#2212)
  NEXUS-4156: Add workspace Leads and choose a chat's workspace (#2788)
  feat(plugins): report conclusively retired update providers (#3263)
  feat(messages): let attachment renderers open files from the host file card (#2239)
  feat(charts): bundle a Charts code-block renderer (#3226)
  Expose native reply grouping through Conversation.ui (#3245)
  NEXUS-4163: Keep plugin-owned chat deletion in Solo (#2787)
  NEXUS-4156: Expose Solo Workspaces to plugin regions (#2786)
  NEXUS-4022: Start region chats in the New Chat editor (#2784)
  feat(link-previews): unfurl web links below messages by default (#3225)
  fix(messages): keep names in literal Markdown as written when editing (#2834)
  feat(plugins): fetch a packaged managed plugin policy from a URL (#2504)
  fix(goose): enable deferred tool loading for goose agents (#3227)
  ...

Signed-off-by: Tyler Longwell <[email protected]>
Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
joahg added a commit to joahg/buzz-app that referenced this pull request Nov 11, 2026
* origin/main:
  NEXUS-4029: Offer folder chips in the composer (block#2789)
  perf(css): narrow effort button invalidation (block#3297)
  NEXUS-4433: Expose saved plugin installation source kind (block#3242)
  fix(identity): keep an unbacked key safe on Replace and unblock a corrupt saved key (block#3233)
  Expose Solo region state before a component mounts (block#3296)
  fix(builderlab): say a 401 was refused, not expired, and show each sign-in failure once (block#3241)
  feat(agents): file new Solo chats in explicit personal workspaces (block#2857)
  perf(css): limit broad style invalidation (block#3267)
  Delay and fade in the Bestie badge celebration (block#3292)
  fix(github): show private-repo body images from signed links (block#3252)
  NEXUS-4029: Preserve semantic chips in the core composer (block#2303)
  Make plugin signature enforcement opt-in (block#2212)
  NEXUS-4156: Add workspace Leads and choose a chat's workspace (block#2788)
  feat(plugins): report conclusively retired update providers (block#3263)
  feat(messages): let attachment renderers open files from the host file card (block#2239)
  feat(charts): bundle a Charts code-block renderer (block#3226)
  Expose native reply grouping through Conversation.ui (block#3245)

Signed-off-by: Joah Gerstenberg <[email protected]>

# Conflicts:
#	src/features/conversation/ComposerCompletions.test.tsx
#	src/features/conversation/ComposerCompletions.tsx
#	src/features/messages/MessageComposer.tsx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants