Tighten replacement bar plugin boundaries

Reported-by: Roger Piñol <rogerpicar@gmail.com>
This commit is contained in:
acrogenesis committed 2026-09-02 11:03:03 -06:00
1 parent 19985314c2
commit 094c913065
12 files changed
+206 -24

No files matched your search

+4 -2
View File
@@ -93,7 +93,9 @@ holds the session lock. The kept instance is not replaced, so code
changes to a `keepLoaded` service itself only take effect on a shell
restart. First-party services are loaded at startup.
Entry points may declare `omarchyPath`, `shell`, `manifest`, `pluginRegistry`, and `barWidgetRegistry` properties for host injection. Built-in plugins receive the trusted host objects. Third-party plugins receive capability-scoped facades: ordinary plugins can look up and control only their own service and lifecycle, menu plugins receive an application-library facade, and plugins can read detached scalar bar state. A full-bar plugin additionally receives detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities are stamped from trusted first-party manifests, authentication services are retained outside the host's public service map and QML object tree, and changing a third-party registry snapshot cannot mutate the host registry.
Entry points may declare `omarchyPath`, `shell`, `manifest`, `pluginRegistry`, and `barWidgetRegistry` properties for host injection. Built-in plugins receive the trusted host objects. Third-party plugins receive capability-scoped facades: ordinary plugins can look up and control only their own service and lifecycle, menu plugins receive an application-library facade, and plugins can read detached scalar bar state. A full-bar plugin additionally receives detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities are stamped from trusted first-party manifests, authentication services are retained outside the host's public service map and QML object tree, and changing a third-party registry or configuration snapshot cannot mutate host state. Facades do not isolate visual widgets from the parent hierarchy of the shared QML scene, so sensitive state must remain outside that reachable graph.
Widgets rendered by a third-party replacement bar receive a service-less entry facade with target-scoped lifecycle and settings operations. Their live service objects are available only when the trusted built-in bar hosts them; otherwise the replacement bar could request and retain any configured widget's service.
The full schema lives in `services/PluginRegistry.qml`.
@@ -110,7 +112,7 @@ omarchy plugin update # updates every git-managed plugin
omarchy plugin remove acme.weather
```
> ⚠️ **Plugins run as unsandboxed code inside `omarchy-shell`.** Adding warns you before cloning, plugins land disabled so you can review the code before enabling, and updates show a diff of the changes before touching anything. The scoped QML interfaces protect shell-owned credentials and cross-plugin controls; they are not an operating-system sandbox. Only add repos whose code you are willing to run.
> ⚠️ **Plugins run as unsandboxed code inside `omarchy-shell`.** Adding warns you before cloning, plugins land disabled so you can review the code before enabling, and updates show a diff of the changes before touching anything. The scoped QML interfaces remove direct authentication-service and generic replacement-bar service lookups, but visual plugins still share and can traverse the ordinary host scene. Only add repos whose code you are willing to run.
Each command is **interactive** when run bare in a terminal (gum pickers,
confirmation, a diff to review) and fully **non-interactive** when given
+2 -1
View File
@@ -2,7 +2,8 @@ import QtQuick
// Bar surface exposed to an installed third-party widget. Scalar presentation
// state is mirrored by Bar.qml and operations are delegated through scoped
// callbacks, so the actual Bar (and its root-shell property) is never retained.
// callbacks. The facade avoids direct host-Bar injection; it cannot isolate a
// visual child from the parent hierarchy of the QML scene that renders it.
QtObject {
id: api
+8 -2
View File
@@ -232,10 +232,16 @@ Item {
if (pluginBarApis[key]) return pluginBarApis[key]
var pluginShell = null
if (registered && root.shell && typeof root.shell.pluginShellForId === "function")
if (registered && root.shell && typeof root.shell.pluginShellForId === "function") {
// Only the trusted built-in bar receives ShellRoot and can request a
// service-capable facade for the widget it is instantiating.
pluginShell = root.shell.pluginShellForId(moduleName)
else if (!registered && root.shell && typeof root.shell.pluginShellForBarEntry === "function")
} else if (root.shell && typeof root.shell.pluginShellForBarEntry === "function") {
// Replacement bars receive a service-less entry facade. Giving an
// untrusted bar a generic facade factory would let it retrieve another
// third-party plugin's live service object.
pluginShell = root.shell.pluginShellForBarEntry(key, moduleName)
}
var api = pluginBarApiComponent.createObject(null, {
pluginId: key,
+1 -2
View File
@@ -78,8 +78,7 @@ QtObject {
}
}
// Every entry point must be a relative path inside the plugin's source
// directory. Reject the whole manifest if anything looks like an attempt
// to escape the plugin's sandbox.
// directory. Reject the whole manifest if an entry point escapes it.
for (var key in manifest.entryPoints) {
if (!isSafeEntryPoint(manifest.entryPoints[key])) {
console.warn("PluginRegistry: unsafe entryPoint '" + key + "'='"
+2 -8
View File
@@ -5,9 +5,8 @@ import QtQuick
// The callbacks are closed over one plugin id by shell.qml. A plugin can call
// them directly, but it cannot widen their scope: ordinary plugins are limited
// to their own id, and full-bar callbacks independently enforce their explicit
// non-authentication UI scope. Keeping the host shell out of this object's
// properties also prevents ordinary QML object traversal from turning the
// facade back into the root ShellRoot.
// non-authentication UI scope. This object avoids directly injecting the host
// shell, but it is not a QML sandbox: visual plugins share the host object tree.
QtObject {
id: api
@@ -19,7 +18,6 @@ QtObject {
property var _serviceLookup: null
property var _firstPartyServiceLookup: null
property var _pluginShellLookup: null
property var _barEntryShellLookup: null
property var _summon: null
property var _hide: null
@@ -39,10 +37,6 @@ QtObject {
? _firstPartyServiceLookup(String(id || "")) : null
}
function pluginShellForId(id) {
return _pluginShellLookup ? _pluginShellLookup(String(id || "")) : null
}
function pluginShellForBarEntry(ownerId, moduleName) {
return _barEntryShellLookup
? _barEntryShellLookup(String(ownerId || ""), String(moduleName || "")) : null
+33 -5
View File
@@ -114,7 +114,10 @@ ShellRoot {
}
readonly property var barConfig: shellConfig && Util.isPlainObject(shellConfig.bar) ? shellConfig.bar : builtinShellConfig.bar
onBarConfigChanged: if (bar && "barConfig" in bar) bar.barConfig = shell.barConfig
onBarConfigChanged: {
if (bar && "barConfig" in bar)
bar.barConfig = shell.barConfigFor(shell.activeBarManifest)
}
FileView {
id: defaultsFile
path: shell.defaultsPath
@@ -219,7 +222,7 @@ ShellRoot {
if ("manifest" in target) target.manifest = shell.publicPluginManifest(manifest)
if ("barWidgetRegistry" in target) target.barWidgetRegistry = shell.pluginBarWidgetRegistryFor(manifest)
if ("pluginRegistry" in target) target.pluginRegistry = shell.pluginRegistryFor(manifest)
if ("barConfig" in target) target.barConfig = shell.barConfig
if ("barConfig" in target) target.barConfig = shell.barConfigFor(manifest)
shell.bar = target
}
@@ -323,6 +326,11 @@ ShellRoot {
return JSON.parse(JSON.stringify(shell.barConfig || {}))
}
function barConfigFor(manifest) {
return !manifest || manifest.__isFirstParty
? shell.barConfig : shell.publicBarConfig()
}
function publicBarWidgetSnapshot() {
var source = shell.barWidgetRegistry.widgets || {}
var snapshot = {}
@@ -513,9 +521,6 @@ ShellRoot {
_firstPartyServiceLookup: function(requestedId) {
return barCapabilities ? (firstPartyServices[requestedId] || null) : null
},
_pluginShellLookup: function(requestedId) {
return barCapabilities ? shell.scopedPluginShellForId(requestedId) : null
},
_barEntryShellLookup: function(ownerId, moduleName) {
return barCapabilities
? shell.pluginShellForBarEntry(cacheKey + ":" + ownerId, moduleName) : null
@@ -575,11 +580,34 @@ ShellRoot {
var owner = String(ownerId || "")
var target = String(moduleName || "")
if (!owner || !target) return null
if (!shell.barEntryConfigured(target)) return null
var cacheKey = owner + "::" + target
if (_pluginBarEntryShellApis[cacheKey]) return _pluginBarEntryShellApis[cacheKey]
function owns(requestedId) {
return shell.pluginRegistry.resolveEnabledId(String(requestedId || ""))
=== shell.pluginRegistry.resolveEnabledId(target)
}
var api = pluginShellApiComponent.createObject(null, {
pluginId: target,
barConfig: shell.publicBarConfig(),
_summon: function(requestedId, payloadJson) {
return owns(requestedId)
? shell.summon(shell.pluginRegistry.resolveEnabledId(target), payloadJson) : false
},
_hide: function(requestedId) {
return owns(requestedId)
? shell.hide(shell.pluginRegistry.resolveEnabledId(target)) : false
},
_toggle: function(requestedId, payloadJson) {
return owns(requestedId)
? shell.toggle(shell.pluginRegistry.resolveEnabledId(target), payloadJson) : false
},
_isOpen: function(requestedId) {
return owns(requestedId)
? shell.isPluginOpen(shell.pluginRegistry.resolveEnabledId(target)) : false
},
_updateSettings: function(requestedId, settings) {
return String(requestedId || "") === target
? shell.updateEntryInline(target, settings) : false