From e80fd4b09bf2291576bc357a551fc12e72a5da58 Mon Sep 17 00:00:00 2001 From: simosmik Date: Fri, 6 Mar 2026 12:09:25 +0000 Subject: [PATCH] fix(plugins): prevent git arg injection, add repo URL detection --- README.md | 2 +- plugins/starter | 1 + server/utils/plugin-loader.js | 29 ++- src/components/plugins/PluginSettingsTab.tsx | 205 ++++++++++--------- src/contexts/PluginsContext.tsx | 1 + 5 files changed, 134 insertions(+), 104 deletions(-) create mode 160000 plugins/starter diff --git a/README.md b/README.md index 25880a89..4acb9610 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ - **Git Explorer** - View, stage and commit your changes. You can also switch branches - **Session Management** - Resume conversations, manage multiple sessions, and track history - **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation -- **Model Compatibility** - Works with Claude Sonnet 4.5, Opus 4.5, GPT-5.2, and Gemini. +- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`shared/modelConstants.js`](shared/modelConstants.js) for the full list of supported models) ## Quick Start diff --git a/plugins/starter b/plugins/starter new file mode 160000 index 00000000..bfa63328 --- /dev/null +++ b/plugins/starter @@ -0,0 +1 @@ +Subproject commit bfa63328103ca330a012bc083e4f934adbc2086e diff --git a/server/utils/plugin-loader.js b/server/utils/plugin-loader.js index bbcdfd28..b95b69ed 100644 --- a/server/utils/plugin-loader.js +++ b/server/utils/plugin-loader.js @@ -89,6 +89,23 @@ export function scanPlugins() { continue; } + // Try to read git remote URL + let repoUrl = null; + try { + const gitConfigPath = path.join(pluginsDir, entry.name, '.git', 'config'); + if (fs.existsSync(gitConfigPath)) { + const gitConfig = fs.readFileSync(gitConfigPath, 'utf-8'); + const match = gitConfig.match(/url\s*=\s*(.+)/); + if (match) { + repoUrl = match[1].trim().replace(/\.git$/, ''); + // Convert SSH URLs to HTTPS + if (repoUrl.startsWith('git@')) { + repoUrl = repoUrl.replace(/^git@([^:]+):/, 'https://$1/'); + } + } + } + } catch { /* ignore */ } + plugins.push({ name: manifest.name, displayName: manifest.displayName, @@ -103,6 +120,7 @@ export function scanPlugins() { permissions: manifest.permissions || [], enabled: config[manifest.name]?.enabled !== false, // enabled by default dirName: entry.name, + repoUrl, }); } catch (err) { console.warn(`[Plugins] Failed to read manifest for ${entry.name}:`, err.message); @@ -137,6 +155,13 @@ export function resolvePluginAssetPath(name, assetPath) { export function installPluginFromGit(url) { return new Promise((resolve, reject) => { + if (typeof url !== 'string' || !url.trim()) { + return reject(new Error('Invalid URL: must be a non-empty string')); + } + if (url.startsWith('-')) { + return reject(new Error('Invalid URL: must not start with "-"')); + } + // Extract repo name from URL for directory name const urlClean = url.replace(/\.git$/, '').replace(/\/$/, ''); const repoName = urlClean.split('/').pop(); @@ -174,7 +199,7 @@ export function installPluginFromGit(url) { resolve(manifest); }; - const gitProcess = spawn('git', ['clone', '--depth', '1', url, tempDir], { + const gitProcess = spawn('git', ['clone', '--depth', '1', '--', url, tempDir], { stdio: ['ignore', 'pipe', 'pipe'], }); @@ -249,7 +274,7 @@ export function updatePluginFromGit(name) { } // Only fast-forward to avoid silent divergence - const gitProcess = spawn('git', ['pull', '--ff-only'], { + const gitProcess = spawn('git', ['pull', '--ff-only', '--'], { cwd: pluginDir, stdio: ['ignore', 'pipe', 'pipe'], }); diff --git a/src/components/plugins/PluginSettingsTab.tsx b/src/components/plugins/PluginSettingsTab.tsx index a918a879..132c9f79 100644 --- a/src/components/plugins/PluginSettingsTab.tsx +++ b/src/components/plugins/PluginSettingsTab.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ChevronRight, ShieldAlert, ExternalLink, BookOpen, Download, BarChart3 } from 'lucide-react'; +import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ShieldAlert, ExternalLink, BookOpen, Download, BarChart3 } from 'lucide-react'; import { usePlugins } from '../../contexts/PluginsContext'; import PluginIcon from './PluginIcon'; import type { Plugin } from '../../contexts/PluginsContext'; @@ -76,7 +76,7 @@ function PluginCard({ return (
-
+
{/* Header row */}
-
+
@@ -101,24 +101,39 @@ function PluginCard({ {plugin.displayName} - + v{plugin.version} - - {plugin.type} + + {plugin.slot}
{plugin.description && ( -

+

{plugin.description}

)} - {plugin.author && ( -

- {plugin.author} -

- )} +
+ {plugin.author && ( + + {plugin.author} + + )} + {plugin.repoUrl && ( + + + + {plugin.repoUrl.replace(/^https?:\/\/(www\.)?github\.com\//, '')} + + + )} +
@@ -156,19 +171,19 @@ function PluginCard({ {/* Confirm uninstall banner */} {confirmingUninstall && (
- + Remove {plugin.displayName}? This cannot be undone.
@@ -178,8 +193,8 @@ function PluginCard({ {/* Update error */} {updateError && ( -
- +
+ {updateError}
)} @@ -191,37 +206,49 @@ function PluginCard({ /* ─── Starter Plugin Card ───────────────────────────────────────────────── */ function StarterPluginCard({ onInstall, installing }: { onInstall: () => void; installing: boolean }) { return ( -
-
-
+
+
+
-
- +
+
- + Project Stats - + starter + + tab +
-

+

File counts, lines of code, file-type breakdown, and recent activity for your project.

+ + + cloudcli-ai/cloudcli-plugin-starter +
@@ -231,25 +258,6 @@ function StarterPluginCard({ onInstall, installing }: { onInstall: () => void; i ); } -/* ─── Empty State ───────────────────────────────────────────────────────── */ -function EmptyState() { - return ( -
-
-
~/.claude-code-ui/plugins/
-
- - (empty) -
-
-

No plugins installed

-

- Install from git or drop a folder in the plugins directory -

-
- ); -} - /* ─── Main Component ────────────────────────────────────────────────────── */ export default function PluginSettingsTab() { const { plugins, loading, installPlugin, uninstallPlugin, updatePlugin, togglePlugin } = @@ -308,35 +316,28 @@ export default function PluginSettingsTab() { const hasStarterInstalled = plugins.some((p) => p.name === 'project-stats'); return ( -
+
{/* Header */} -
-
-

- Plugins -

-

- Extend the interface with custom plugins. Install from{' '} - - git - {' '} - or drop a folder in{' '} - - ~/.claude-code-ui/plugins/ - -

-
- {!loading && plugins.length > 0 && ( - - {plugins.filter((p) => p.enabled).length}/{plugins.length} - - )} +
+

+ Plugins +

+

+ Extend the interface with custom plugins. Install from{' '} + + git + {' '} + or drop a folder in{' '} + + ~/.claude-code-ui/plugins/ + +

{/* Install from Git — compact */} -
+
- + { if (e.key === 'Enter') void handleInstall(); }} @@ -354,10 +355,10 @@ export default function PluginSettingsTab() {
{installError && ( -

{installError}

+

{installError}

)} -

+

Only install plugins whose source code you have reviewed or from authors you trust.

+ {/* Starter plugin suggestion — above the list */} + {!loading && !hasStarterInstalled && ( + + )} + {/* Plugin List */} -
- {loading ? ( -
- - scanning plugins… -
- ) : plugins.length === 0 ? ( - - ) : ( - plugins.map((plugin, index) => ( + {loading ? ( +
+ + Scanning plugins… +
+ ) : plugins.length === 0 && hasStarterInstalled ? ( +

No plugins installed

+ ) : plugins.length === 0 ? ( +

No plugins installed

+ ) : ( +
+ {plugins.map((plugin, index) => ( setConfirmUninstall(null)} updateError={updateErrors[plugin.name] ?? null} /> - )) - )} - - {/* Starter plugin suggestion */} - {!loading && !hasStarterInstalled && ( - - )} -
+ ))} +
+ )} {/* Build your own */} -
+
- + Build your own plugin
Starter @@ -429,7 +432,7 @@ export default function PluginSettingsTab() { href="https://cloudcli.ai/docs/plugin-overview" target="_blank" rel="noopener noreferrer" - className="inline-flex items-center gap-1 text-[11px] text-muted-foreground/60 hover:text-foreground transition-colors" + className="inline-flex items-center gap-1 text-xs text-muted-foreground/60 hover:text-foreground transition-colors" > Docs diff --git a/src/contexts/PluginsContext.tsx b/src/contexts/PluginsContext.tsx index 987a7f61..ccf404ef 100644 --- a/src/contexts/PluginsContext.tsx +++ b/src/contexts/PluginsContext.tsx @@ -17,6 +17,7 @@ export type Plugin = { enabled: boolean; serverRunning: boolean; dirName: string; + repoUrl: string | null; }; type PluginsContextValue = {