From aad2aac696699bae6b30cdf7f536370e9647293f Mon Sep 17 00:00:00 2001 From: Stefan Ernst <24554551+stefan-ernst@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:05:28 +0100 Subject: [PATCH] feat(plugins): enforce host-function permissions (WI-1821) Plugin host functions were registered for every plugin, so a plugin that declared a minimal capability set still received process execution, outbound HTTP/SMTP, KV, comment and SCM access (security finding F2). - New manifest field `permissions` with a closed catalog: process.exec -> cli_exec, network.egress -> http_fetch, mail.send -> smtp_send, storage.kv -> kv_get/kv_set/kv_delete, comments.write -> create_comment, scm.write -> scm_create_branch/scm_create_item_link. `log` stays always available. - Unknown permission names fail the load; declared permissions are normalized and deduplicated. - Every gated host function checks the calling plugin's effective permissions and returns a typed denial instead of executing. An absent plugin context denies. - License precedence is permission AND license: an unlicensed plugin keeps loading but gets no host-function permissions. - The admin plugin list reports requested vs effective permissions, and module settings renders a badge per permission. - A load-time warning names the plugin and the host functions it can no longer call. - NewManager validates the registered host function set against the catalog so a new host function cannot ship unguarded. --- frontend/src/lib/locales/de/admin.js | 4 + frontend/src/lib/locales/en/admin.js | 4 + frontend/src/lib/locales/fr/admin.js | 4 + frontend/src/lib/locales/ru/admin.js | 4 + frontend/src/lib/locales/zh-CN/admin.js | 4 + .../src/lib/settings/ModuleSettings.svelte | 28 +++- internal/handlers/plugins.go | 34 +++-- internal/plugins/capabilities.go | 121 ++++++++++++++++++ internal/plugins/cli_host.go | 8 ++ internal/plugins/host_functions.go | 40 ++++++ internal/plugins/manager.go | 86 +++++++++++-- internal/plugins/manager_stub.go | 3 + internal/plugins/types.go | 24 ++-- 13 files changed, 332 insertions(+), 32 deletions(-) create mode 100644 internal/plugins/capabilities.go diff --git a/frontend/src/lib/locales/de/admin.js b/frontend/src/lib/locales/de/admin.js index 094e8d09f..00e4f660f 100644 --- a/frontend/src/lib/locales/de/admin.js +++ b/frontend/src/lib/locales/de/admin.js @@ -836,6 +836,10 @@ export default { routeAuth_session: 'Sitzung', routeAuth_admin: 'Administrator', routeAuth_none: 'Öffentlich', + hostFunctionPermissions: 'Host-Funktionsberechtigungen', + permissionGranted: 'Erteilt', + permissionDenied: 'Verweigert', + permissionsDeniedNote: 'Einige deklarierte Berechtigungen sind nicht wirksam.', moduleSettingsNote: 'Änderungen an Moduleinstellungen werden sofort wirksam. Das Deaktivieren eines Moduls blendet es aus der Navigation aus.', failedToUpload: 'Hochladen fehlgeschlagen: {error}', failedToReload: 'Plugin konnte nicht neu geladen werden: {error}', diff --git a/frontend/src/lib/locales/en/admin.js b/frontend/src/lib/locales/en/admin.js index 57b3c741b..bbd42dab7 100644 --- a/frontend/src/lib/locales/en/admin.js +++ b/frontend/src/lib/locales/en/admin.js @@ -903,6 +903,10 @@ export default { routeAuth_session: 'Session', routeAuth_admin: 'Admin', routeAuth_none: 'Public', + hostFunctionPermissions: 'Host Function Permissions', + permissionGranted: 'Granted', + permissionDenied: 'Denied', + permissionsDeniedNote: 'Some declared permissions are not in effect.', moduleSettingsNote: 'Changes to module settings take effect immediately. Disabling a module will hide it from the navigation.', failedToUpload: 'Upload failed: {error}', diff --git a/frontend/src/lib/locales/fr/admin.js b/frontend/src/lib/locales/fr/admin.js index ff8e4469d..54cd92bcc 100644 --- a/frontend/src/lib/locales/fr/admin.js +++ b/frontend/src/lib/locales/fr/admin.js @@ -845,6 +845,10 @@ export default { routeAuth_session: 'Session', routeAuth_admin: 'Administrateur', routeAuth_none: 'Public', + hostFunctionPermissions: 'Permissions des fonctions hôtes', + permissionGranted: 'Accordée', + permissionDenied: 'Refusée', + permissionsDeniedNote: 'Certaines permissions déclarées ne sont pas effectives.', moduleSettingsNote: 'Les modifications des paramètres de module prennent effet immédiatement. Désactiver un module le masquera de la navigation.', failedToUpload: 'Échec du téléversement : {error}', diff --git a/frontend/src/lib/locales/ru/admin.js b/frontend/src/lib/locales/ru/admin.js index 5490cd9f7..c4e7c191a 100644 --- a/frontend/src/lib/locales/ru/admin.js +++ b/frontend/src/lib/locales/ru/admin.js @@ -942,6 +942,10 @@ export default { routeAuth_session: 'Сессия', routeAuth_admin: 'Администратор', routeAuth_none: 'Публичный', + hostFunctionPermissions: 'Разрешения хост-функций', + permissionGranted: 'Предоставлено', + permissionDenied: 'Отклонено', + permissionsDeniedNote: 'Некоторые объявленные разрешения не действуют.', moduleSettingsNote: 'Изменения настроек модулей применяются сразу. Отключённый модуль будет скрыт из навигации.', failedToUpload: 'Не удалось загрузить: {error}', diff --git a/frontend/src/lib/locales/zh-CN/admin.js b/frontend/src/lib/locales/zh-CN/admin.js index 3c37d065d..e2218cd3c 100644 --- a/frontend/src/lib/locales/zh-CN/admin.js +++ b/frontend/src/lib/locales/zh-CN/admin.js @@ -726,6 +726,10 @@ export default { routeAuth_session: '会话', routeAuth_admin: '管理员', routeAuth_none: '公开', + hostFunctionPermissions: '宿主函数权限', + permissionGranted: '已授予', + permissionDenied: '已拒绝', + permissionsDeniedNote: '部分声明的权限未生效。', moduleSettingsNote: '模块设置的更改会立即生效。禁用模块将从导航中隐藏它。', failedToUpload: '上传失败:{error}', failedToReload: '重新加载插件失败:{error}', diff --git a/frontend/src/lib/settings/ModuleSettings.svelte b/frontend/src/lib/settings/ModuleSettings.svelte index db2c6090a..98339f058 100644 --- a/frontend/src/lib/settings/ModuleSettings.svelte +++ b/frontend/src/lib/settings/ModuleSettings.svelte @@ -550,8 +550,34 @@ {/if} + + {#if plugin.requested_permissions && plugin.requested_permissions.length > 0} +
+

{t('settings.modules.hostFunctionPermissions')}:

+
+ {#each plugin.requested_permissions as permission, permissionIndex} + {@const granted = (plugin.effective_permissions || []).includes(permission)} + + {permission} + + {/each} +
+ {#if (plugin.effective_permissions || []).length < plugin.requested_permissions.length} +

{t('settings.modules.permissionsDeniedNote')}

+ {/if} +
+ {/if} - + {#if !pluginsDisabled}