Skip to content

fix(plugins): disable auto-running npm run build and stage plugin updates (P0) - #1117

Open
wjc2821296948 wants to merge 5 commits into
siteboon:mainfrom
wjc2821296948:fix/plugin-install-rce
Open

fix(plugins): disable auto-running npm run build and stage plugin updates (P0)#1117
wjc2821296948 wants to merge 5 commits into
siteboon:mainfrom
wjc2821296948:fix/plugin-install-rce

Conversation

@wjc2821296948

@wjc2821296948 wjc2821296948 commented Aug 7, 2026

Copy link
Copy Markdown

P0 — Plugin install executes npm run build on attacker-controlled repositories

Vulnerability description

installPluginFromGit(url) and updatePluginFromGit(name) in server/modules/plugins/plugin-registry.service.ts invoked runBuildIfNeeded() immediately after npm install. npm install resolves transitive dependencies and runs the preinstall/install/postinstall lifecycle scripts of any package the registry pulls in. The build step then additionally executed whatever build script the repository's package.json declared. Because both routes accept an arbitrary URL from the client (POST /api/plugins/install) or pull from a stored remote (POST /api/plugins/:name/update), an attacker who convinced an authenticated operator to install or update a plugin could execute arbitrary Node.js code on the server host under the server's user account. The credentials available to that code (env, plugin directory contents, on-disk auth state) include everything needed to fully compromise the application.

Fix

Disable the implicit npm run build step on both install and update. Operators who want to build a plugin now have to opt in explicitly by passing allowBuild: true in the request body after they have manually inspected the repository. The registry surfaces the opt-in through a single runBuildIfNeeded(pluginDir, { allowBuild }) parameter; routes pass allowBuild straight from the request body. The plugin update path was also rewritten to stage the new tree in a temporary directory (re-clone → validate manifest → npm install → build policy → atomic rename) so a rejected update never disturbs the live plugin and so a preinstall/install/postinstall failure leaves the existing plugin running. The plugin update service stops the running plugin server before the swap and restarts it on success and on rejection, so the previous behaviour of leaving the server down after a bad update is no longer reachable.

Files

  • server/modules/plugins/plugin-registry.service.ts
  • server/modules/plugins/plugins.routes.ts
  • server/modules/plugins/plugins.service.ts
  • server/modules/plugins/tests/plugin-registry.service.test.ts (new)
  • server/modules/plugins/tests/plugins.service.test.ts

Commits

  • df75870fix(plugins): disable auto-running npm run build during plugin install
  • 8954fa1fix(plugins): stage plugin updates so a rejected update leaves the live plugin untouched
  • 0c2f5e9fix(plugins): preserve the live plugin directory when the update swap fails
  • 4dd03c2fix(plugins): require https:// or git@ scheme in installPluginFromGit

Code snippet — runBuildIfNeeded now requires explicit consent

export function runBuildIfNeeded(pluginDir: string, opts: { allowBuild: boolean }) {
  if (!opts.allowBuild && !process.env.CLAUDECODEUI_ALLOW_PLUGIN_BUILD) {
    return Promise.resolve();
  }
  // ... unchanged: spawn npm run build ...
}

Code snippet — registry now refuses non-https / non-git remotes

if (!url.startsWith('https://') && !url.startsWith('git@')) {
  return reject(new Error('Invalid URL: only https:// and git@ remotes are supported'));
}

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Security & Reliability

    • Plugin builds are disabled by default and require explicit opt-in.
    • Git installations accept only secure HTTPS or SSH remotes.
    • Plugin updates include validation and rollback safeguards to protect working plugins.
  • Bug Fixes

    • Running plugin servers automatically restart after failed updates.
    • Invalid Git sources are rejected with clear errors.

wjc2821296948 and others added 4 commits August 7, 2026 14:53
`installPluginFromGit` and `updatePluginFromGit` cloned a remote Git repository
and ran `npm run build` whenever the package.json declared a build script.
Build scripts execute arbitrary code with the server process's privileges, so
any party able to supply a plugin URL (e.g. an authenticated user tricked into
pasting a malicious URL, or a compromised auth token) gained remote code
execution on the CloudCLI host.

The build script is now opt-in: the caller must pass `allowBuild: true` to the
install/update service after manually inspecting the build command. The HTTP
`POST /api/plugins/install` and `POST /api/plugins/<name>/update` endpoints
accept an explicit `allowBuild: true` in the JSON body for that purpose. A
process-wide escape hatch (`setAllowPluginBuildScript`) is exposed for tests.

Co-authored-by: cgsdn <chaogeshuodiannao@users.noreply.github.com>
…ve plugin untouched

`updatePluginFromGit` performed `git pull --ff-only` directly against the
live plugin directory. After my previous commit made `runBuildIfNeeded`
reject updates whose `package.json` declares a build script without
`allowBuild: true`, that rejection now happened *after* the pull had
already mutated the live directory (and after `npm install
--ignore-scripts` had already rewritten `node_modules`). The caller
(`plugins.service.ts update()`) had also already stopped the running
plugin server before invoking the registry. A rejected update therefore
left the operator with both a half-updated plugin directory and a
stopped plugin server.

Switch the registry to the same staging pattern `installPluginFromGit`
already uses: re-clone the plugin's remote URL into a sibling temp
directory, validate the manifest, run `npm install`, apply the build
policy, and only then atomically rename the temp directory over the
live one. A rejection at any step cleans up the temp directory and the
live plugin directory is never touched.

Update the service to restart the previously running plugin server when
the update is rejected — the live directory is unchanged, so a clean
restart restores the previous plugin state.

Cover the new contract with a service test that verifies a rejected
update stops, attempts the update, and then restarts the previously
running server.

Co-authored-by: cgsdn <chaogeshuodiannao@users.noreply.github.com>
… fails

`updatePluginFromGit`'s `finalize` step performed `fs.rmSync(pluginDir)`
followed by `fs.renameSync(tempDir, pluginDir)`. If the rename failed
(partition full, permissions race, Windows AV scanner holding a file
handle, etc.) the temp directory was cleaned up by the catch block
but the previous plugin directory was already gone. The previous
plugin was lost and `plugins.service.ts update()` could not load the
previous manifest during server recovery.

Switch to a backup-restore pattern: rename the live directory to a
sibling backup, rename the temp directory into place, then delete the
backup. If the second rename fails, restore the backup to the live
directory and clean up the temp directory. The previous plugin is
always recoverable from either the live path or the backup.

Add a small smoke test for the registry's URL pre-checks (full
swap-failure coverage would require an fs mock framework that this
codebase does not currently depend on).

Co-authored-by: cgsdn <chaogeshuodiannao@users.noreply.github.com>
`installPluginFromGit` previously validated only that the URL was a
non-empty string that did not start with `-`. Any other shape
(including `file://` and `http://`) passed through to `git clone`,
and the registry's `repoName` regex happened to accept paths like
`/tmp/local`. The HTTP route layer already enforces `https://`/`git@`
upstream, but the registry should also enforce the scheme so any
internal caller (tests, future programmatic install paths) cannot
bypass the check. Reject URLs that do not start with `https://` or
`git@` with a clear `Invalid URL` error before any disk work.

Co-authored-by: cgsdn <chaogeshuodiannao@users.noreply.github.com>
@wjc2821296948

Copy link
Copy Markdown
Author

Severity summary

P0 — Plugin install and update execute npm run build on attacker-controlled repositories.

This PR is split out from the previous umbrella PR (#1106) per @blackmammoth's request that each finding be reviewed in isolation.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7703f8b2-6480-4359-b8e8-149002440d7a

📥 Commits

Reviewing files that changed from the base of the PR and between 4dd03c2 and 2238343.

📒 Files selected for processing (2)
  • server/modules/plugins/plugin-registry.service.ts
  • server/modules/plugins/plugins.service.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • server/modules/plugins/plugins.service.ts
  • server/modules/plugins/plugin-registry.service.ts

📝 Walkthrough

Walkthrough

Plugin installation and updates now validate Git remotes, disable build scripts by default, support explicit build authorization, stage updates in temporary directories, and restore previous plugin state after failures.

Changes

Plugin safety and lifecycle

Layer / File(s) Summary
Build policy and installation wiring
server/modules/plugins/plugin-registry.service.ts, server/modules/plugins/plugins.routes.ts, server/modules/plugins/plugins.service.ts, server/modules/plugins/tests/plugin-registry.service.test.ts
Build scripts require global or per-operation authorization. Git installation accepts only https:// and git@ remotes. Routes and services forward allowBuild. URL rejection tests cover invalid inputs.
Staged update and recovery
server/modules/plugins/plugin-registry.service.ts, server/modules/plugins/plugins.service.ts, server/modules/plugins/tests/plugins.service.test.ts
Updates validate the origin, clone into temporary storage, validate the manifest, install dependencies without scripts, apply build policy, and atomically replace the live plugin. Failed updates restore the previous state and restart previously running servers.

Sequence Diagram(s)

sequenceDiagram
  participant PluginRoutes
  participant PluginsService
  participant PluginRegistry
  participant TemporaryDirectory
  participant LivePlugin

  PluginRoutes->>PluginsService: pass allowBuild
  PluginsService->>PluginRegistry: install or update plugin
  PluginRegistry->>TemporaryDirectory: validate, clone, and prepare plugin
  PluginRegistry->>LivePlugin: atomically replace plugin
  PluginsService->>LivePlugin: restart server after failed update
Loading

Possibly related PRs

Poem

Poem

A rabbit checks each Git door,
And keeps builds off by default.
A plugin grows in a temporary burrow,
Then replaces the live tree.
If the update fails,
The server starts again.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary changes: disabling automatic plugin builds and staging plugin updates.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@wjc2821296948

Copy link
Copy Markdown
Author

df75870fix(plugins): disable auto-running npm run build during plugin install

Severity

P0 — RCE via plugin build script.

Description

installPluginFromGit(url) and updatePluginFromGit(name) in server/modules/plugins/plugin-registry.service.ts invoked runBuildIfNeeded() immediately after npm install. npm install resolves transitive dependencies and runs the preinstall/install/postinstall lifecycle scripts of any package the registry pulls in. The build step then additionally executed whatever build script the repository's package.json declared. Because both routes accept an arbitrary URL from the client (POST /api/plugins/install) or pull from a stored remote (POST /api/plugins/:name/update), an attacker who convinced an authenticated operator to install or update a plugin could execute arbitrary Node.js code on the server host under the server's user account. The credentials available to that code (env, plugin directory contents, on-disk auth state) include everything needed to fully compromise the application.

Fix

Disable the implicit npm run build step on both install and update. Operators who want to build a plugin now have to opt in explicitly by passing allowBuild: true in the request body after they have manually inspected the repository. The registry surfaces the opt-in through a single runBuildIfNeeded(pluginDir, { allowBuild }) parameter; routes pass allowBuild straight from the request body. A process-wide escape hatch (setAllowPluginBuildScript) is exposed for tests.

Code snippet

export function runBuildIfNeeded(pluginDir: string, opts: { allowBuild: boolean }) {
  if (!opts.allowBuild && !process.env.CLAUDECODEUI_ALLOW_PLUGIN_BUILD) {
    return Promise.resolve();
  }
  // ... unchanged: spawn npm run build ...
}

🤖 Generated with Claude Code

@wjc2821296948

Copy link
Copy Markdown
Author

8954fa1fix(plugins): stage plugin updates so a rejected update leaves the live plugin untouched

Severity

P0 (corollary) — state corruption after rejected update, defeating the previous commit's defence.

Description

updatePluginFromGit performed git pull --ff-only directly against the live plugin directory. After the previous commit made runBuildIfNeeded reject updates whose package.json declares a build script without allowBuild: true, that rejection now happened after the pull had already mutated the live directory (and after npm install --ignore-scripts had already rewritten node_modules). The caller (plugins.service.ts update()) had also already stopped the running plugin server before invoking the registry. A rejected update therefore left the operator with both a half-updated plugin directory and a stopped plugin server.

Fix

Switch the registry to the same staging pattern installPluginFromGit already uses: re-clone the plugin's remote URL into a sibling temp directory, validate the manifest, run npm install, apply the build policy, and only then atomically rename the temp directory over the live one. A rejection at any step cleans up the temp directory and the live plugin directory is never touched.

Update the service to restart the previously running plugin server when the update is rejected — the live directory is unchanged, so a clean restart restores the previous plugin state.

Cover the new contract with a service test that verifies a rejected update stops, attempts the update, and then restarts the previously running server.

🤖 Generated with Claude Code

@wjc2821296948

Copy link
Copy Markdown
Author

0c2f5e9fix(plugins): preserve the live plugin directory when the update swap fails

Severity

P0 (corollary) — incomplete atomic swap; previous plugin destroyed on Windows rename failure.

Description

updatePluginFromGit's finalize step performed fs.rmSync(pluginDir) followed by fs.renameSync(tempDir, pluginDir). If the rename failed (partition full, permissions race, Windows AV scanner holding a file handle, etc.) the temp directory was cleaned up by the catch block but the previous plugin directory was already gone. The previous plugin was lost and plugins.service.ts update() could not load the previous manifest during server recovery.

Fix

Switch to a backup-restore pattern: rename the live directory to a sibling backup, rename the temp directory into place, then delete the backup. If the second rename fails, restore the backup to the live directory and clean up the temp directory. The previous plugin is always recoverable from either the live path or the backup. Add a small smoke test for the registry's URL pre-checks (full swap-failure coverage would require an fs mock framework that this codebase does not currently depend on).

🤖 Generated with Claude Code

@wjc2821296948

Copy link
Copy Markdown
Author

4dd03c2fix(plugins): require https:// or git@ scheme in installPluginFromGit

Severity

P0 (corollary) — scheme-bypass for the install path.

Description

installPluginFromGit previously validated only that the URL was a non-empty string that did not start with -. Any other shape (including file:// and http://) passed through to git clone, and the registry's repoName regex happened to accept paths like /tmp/local. The HTTP route layer already enforces https:///git@ upstream, but the registry should also enforce the scheme so any internal caller (tests, future programmatic install paths) cannot bypass the check.

Fix

Reject URLs that do not start with https:// or git@ with a clear Invalid URL error before any disk work.

Code snippet

if (!url.startsWith('https://') && !url.startsWith('git@')) {
  return reject(new Error('Invalid URL: only https:// and git@ remotes are supported'));
}

🤖 Generated with Claude Code

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 3

🧹 Nitpick comments (3)
server/modules/plugins/tests/plugins.service.test.ts (1)

34-57: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert that update() forwards its options.

The test verifies the restart order correctly. It calls service.update('demo') without options, so the new allowBuild plumbing stays uncovered at this layer. Capture the second argument in the update stub and assert it equals the options passed in.

let receivedOptions: unknown;
// ...
update: async (_name, options) => { receivedOptions = options; return { name: 'demo', dirName: 'demo' }; },
// ...
await service.update('demo', { allowBuild: true });
assert.deepEqual(receivedOptions, { allowBuild: true });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/modules/plugins/tests/plugins.service.test.ts` around lines 34 - 57,
Update the `update()` restart test to pass `{ allowBuild: true }` to
`service.update('demo', ...)`, capture the options argument in the mocked
`update` dependency, and assert it matches the provided options while preserving
the existing rejection and restart-order assertions.
server/modules/plugins/tests/plugin-registry.service.test.ts (1)

6-30: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the build-policy decision.

The three tests cover URL pre-checks only. The core change of this PR is that a plugin declaring a build script now fails unless the caller opts in. No test asserts that behavior, and no test asserts that setAllowPluginBuildScript(true) re-enables it.

runBuildIfNeeded is not exported, so a direct unit test needs either an export or a local fixture repository. Do you want me to add a test that exports runBuildIfNeeded for testing and covers the three branches: no build script, build script without opt-in, build script with allowBuild: true?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/modules/plugins/tests/plugin-registry.service.test.ts` around lines 6
- 30, Add tests covering the build-policy branches in installPluginFromGit or
the relevant registry flow: allow plugins without a build script, reject plugins
declaring a build script unless opt-in is enabled, and permit the build when
setAllowPluginBuildScript(true) or allowBuild: true is applied. Use an existing
local fixture repository if possible; otherwise expose runBuildIfNeeded only as
needed for focused testing.
server/modules/plugins/plugin-registry.service.ts (1)

100-135: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Consider narrowing the process-wide build override.

ALLOW_PLUGIN_BUILD_SCRIPT is a mutable module global. Once any caller sets it to true, every later install and update can run build scripts, including requests that did not opt in. The per-request allowBuild flag then no longer controls the behavior.

If the override exists only for tests and vetted callers, gate it on an environment variable read once at startup, or keep the setter but document that it disables the per-request consent model.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/modules/plugins/plugin-registry.service.ts` around lines 100 - 135,
Restrict the process-wide override used by setAllowPluginBuildScript so enabling
it cannot silently allow builds for all later install and update requests.
Prefer gating the override behind an environment variable evaluated once at
startup, while preserving options.allowBuild as the normal per-request consent
path; alternatively, explicitly document and enforce that the setter
intentionally bypasses per-request consent.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@server/modules/plugins/plugin-registry.service.ts`:
- Around line 458-475: Validate remoteUrl immediately after extracting it in the
re-cloning flow, before invoking spawn for git clone. Apply the same allowlist
used by installPluginFromGit: permit only https:// and git@ remotes, and reject
all other schemes by cleaning up and rejecting with an appropriate error. Keep
the existing missing-remote handling unchanged.
- Around line 424-452: Update the backupDir naming in finalize so the backup
path uses a `.tmp-` prefixed basename, while retaining the plugin identity and
unique process/timestamp suffix. Keep the existing rollback and cleanup behavior
unchanged so the backup remains recoverable but is ignored by scanPlugins().

In `@server/modules/plugins/plugins.service.ts`:
- Around line 124-132: The catch block in the plugin update flow must preserve
the original update error even when recovery fails. Wrap the wasRunning recovery
call to startServerIfAvailable(this.getManifest(pluginName)) in its own guarded
attempt, suppress any recovery error, and rethrow the original caught error
unchanged.

---

Nitpick comments:
In `@server/modules/plugins/plugin-registry.service.ts`:
- Around line 100-135: Restrict the process-wide override used by
setAllowPluginBuildScript so enabling it cannot silently allow builds for all
later install and update requests. Prefer gating the override behind an
environment variable evaluated once at startup, while preserving
options.allowBuild as the normal per-request consent path; alternatively,
explicitly document and enforce that the setter intentionally bypasses
per-request consent.

In `@server/modules/plugins/tests/plugin-registry.service.test.ts`:
- Around line 6-30: Add tests covering the build-policy branches in
installPluginFromGit or the relevant registry flow: allow plugins without a
build script, reject plugins declaring a build script unless opt-in is enabled,
and permit the build when setAllowPluginBuildScript(true) or allowBuild: true is
applied. Use an existing local fixture repository if possible; otherwise expose
runBuildIfNeeded only as needed for focused testing.

In `@server/modules/plugins/tests/plugins.service.test.ts`:
- Around line 34-57: Update the `update()` restart test to pass `{ allowBuild:
true }` to `service.update('demo', ...)`, capture the options argument in the
mocked `update` dependency, and assert it matches the provided options while
preserving the existing rejection and restart-order assertions.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1599a5bd-e106-4f76-bce4-b0694ee962c5

📥 Commits

Reviewing files that changed from the base of the PR and between f0dca2d and 4dd03c2.

📒 Files selected for processing (5)
  • server/modules/plugins/plugin-registry.service.ts
  • server/modules/plugins/plugins.routes.ts
  • server/modules/plugins/plugins.service.ts
  • server/modules/plugins/tests/plugin-registry.service.test.ts
  • server/modules/plugins/tests/plugins.service.test.ts

Comment thread server/modules/plugins/plugin-registry.service.ts
Comment thread server/modules/plugins/plugin-registry.service.ts
Comment thread server/modules/plugins/plugins.service.ts
…lone RCE

Three CodeRabbit follow-ups on the plugin update staging flow:

1. `backupDir` was named `${pluginDir}.previous-<pid>-<timestamp>`,
   a sibling inside the plugins directory. `scanPlugins()` skips only
   entries whose name starts with `.tmp-`, so the backup would have
   been picked up by `scanPlugins()` during the swap window and (if
   the restore rename also failed) permanently. Move the backup
   under a `.tmp-previous-` prefix so it stays recoverable but
   invisible to scanning.

2. `updatePluginFromGit` re-clones the plugin's stored remote URL into
   a temp directory. The HTTP route layer enforces `https://`/`git@`,
   but the registry re-reads the URL from `.git/config` and passed it
   straight to `git clone` with no scheme check. A `.git/config`
   containing an `ext::` or `file://` remote (writable by any process
   with file-system access to the plugin directory, including the
   plugin's own server) would have been honoured by `git clone`,
   and `ext::` runs a shell command. Apply the same `https://` /
   `git@` allowlist used by `installPluginFromGit`.

3. `plugins.service.ts update()`'s catch block restarted the
   previously running server unconditionally and then rethrew. If
   `startServerIfAvailable(this.getManifest(pluginName))` itself
   threw (e.g. `getManifest` returned `PLUGIN_NOT_FOUND` because
   `scanPlugins()` had evicted the entry), the recovery error would
   mask the original update error. Isolate the recovery attempt so
   the original error always propagates and a recovery failure is
   logged but not thrown.

Co-authored-by: cgsdn <chaogeshuodiannao@users.noreply.github.com>
@wjc2821296948

Copy link
Copy Markdown
Author

2238343fix(plugins): harden update swap against backup scan leakage and re-clone RCE

Three CodeRabbit follow-ups on the plugin update staging flow:

T1 — backup directory leaked through scanPlugins()

backupDir was named ${pluginDir}.previous-<pid>-<timestamp>, a sibling inside the plugins directory. scanPlugins() skips only entries whose name starts with .tmp-, so the backup would have been picked up by scanPlugins() during the swap window and (if the restore rename also failed) permanently. Move the backup under a .tmp-previous- prefix so it stays recoverable but invisible to scanning.

- ? `${pluginDir}.previous-${process.pid}-${Date.now()}`
+ ? path.join(pluginsDir, `.tmp-previous-${path.basename(pluginDir)}-${process.pid}-${Date.now()}`)

T2 — re-clone path missed the scheme allowlist

updatePluginFromGit re-clones the plugin's stored remote URL into a temp directory. The HTTP route layer enforces https:///git@, but the registry re-reads the URL from .git/config and passed it straight to git clone with no scheme check. A .git/config containing an ext:: or file:// remote (writable by any process with file-system access to the plugin directory, including the plugin's own server) would have been honoured by git clone, and ext:: runs a shell command. Apply the same https:// / git@ allowlist used by installPluginFromGit.

T3 — recovery path could mask the original update error

plugins.service.ts update()'s catch block restarted the previously running server unconditionally and then rethrew. If startServerIfAvailable(this.getManifest(pluginName)) itself threw (e.g. getManifest returned PLUGIN_NOT_FOUND because scanPlugins() had evicted the entry), the recovery error would mask the original update error. Isolate the recovery attempt so the original error always propagates and a recovery failure is logged but not thrown.

- if (wasRunning) await startServerIfAvailable(this.getManifest(pluginName));
+ if (wasRunning) {
+   try {
+     await startServerIfAvailable(this.getManifest(pluginName));
+   } catch (restoreError) {
+     dependencies.logError(`Failed to restart plugin server for ${pluginName} after a failed update`, restoreError);
+   }
+ }
  throw error;

🤖 Generated with Claude Code

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.

1 participant