From ac201deb351ef5d74c85e662aaace47f5dcd045f Mon Sep 17 00:00:00 2001 From: jubnl Date: Sat, 11 Jul 2026 20:06:54 +0200 Subject: [PATCH] fix(sdk): allow empty egress if and only if operatorEgress is true --- plugin-sdk/src/cli/create.ts | 50 +++++++++++++------ plugin-sdk/src/cli/preflight.ts | 5 +- plugin-sdk/src/manifest.ts | 6 ++- plugin-sdk/test/cli.test.ts | 21 +++++++- plugin-sdk/test/sdk.test.ts | 9 ++++ server/src/nest/plugins/install/manifest.ts | 12 ++++- .../unit/plugins/operator-egress.test.ts | 20 ++++++++ wiki/Plugin-Development.md | 22 +++++++- wiki/Plugin-Permissions.md | 8 ++- 9 files changed, 127 insertions(+), 26 deletions(-) diff --git a/plugin-sdk/src/cli/create.ts b/plugin-sdk/src/cli/create.ts index f9ad7d208..9c53ebdac 100644 --- a/plugin-sdk/src/cli/create.ts +++ b/plugin-sdk/src/cli/create.ts @@ -10,7 +10,7 @@ import os from 'node:os'; import { execFileSync } from 'node:child_process'; import { createRequire } from 'node:module'; import { - intro, outro, note, logSuccess, logWarn, spinner, + intro, outro, note, logInfo, logSuccess, logWarn, spinner, promptText, promptSelect, promptMultiselect, promptConfirm, PERMISSION_CATALOG, } from './ui.js'; @@ -30,8 +30,14 @@ export interface ScaffoldOptions { author?: string; description?: string; permissions?: string[]; - /** External hosts the plugin may call — required by the manifest when `http:outbound` is granted. */ + /** + * External hosts the plugin may call — required by the manifest when `http:outbound` is + * granted, UNLESS the plugin is an operatorEgress one (below), whose hosts an admin adds + * after install because the author cannot know them (a self-hosted Gotify, an ntfy). + */ egress?: string[]; + /** The admin supplies the hosts post-install. Defaults on for a channel, and whenever `http:outbound` is wanted with no `egress`. */ + operatorEgress?: boolean; /** Addon ids that must be enabled for this plugin to activate. */ requiredAddons?: string[]; /** Other plugins this one depends on, each pinned by a semver range. */ @@ -62,14 +68,20 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc const displayName = name.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); let perms = opts.permissions?.length ? opts.permissions : ['db:own']; - let egress = opts.egress ?? []; + const egress = opts.egress ?? []; if (isChannel) { - // The two grants a channel cannot work without, plus a placeholder host the - // author swaps for their own — the manifest rejects http:outbound with no egress. - if (!egress.length) egress = ['gotify.example.com']; - const required = ['hook:notification-channel', ...egress.map((h) => `http:outbound:${h}`)]; + // The grants a channel cannot work without. With known hosts it gets a per-host grant + // each; with none — the usual case for a self-hosted target — it gets the bare + // `http:outbound` and relies on operatorEgress below, rather than a fake placeholder + // host that would otherwise ship in the published manifest. + const outbound = egress.length ? egress.map((h) => `http:outbound:${h}`) : ['http:outbound']; + const required = ['hook:notification-channel', ...outbound]; perms = [...new Set([...perms.filter((p) => p !== 'db:own'), ...required])]; } + // A plugin that wants outbound but names no host is only valid as an operatorEgress + // plugin — the admin supplies the hosts after install. + const wantsOutbound = perms.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:')); + const operatorEgress = opts.operatorEgress ?? (isChannel || (wantsOutbound && egress.length === 0)); const manifest: Record = { id: name, @@ -79,7 +91,7 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc author: opts.author || 'Your Name', description: opts.description || (isChannel ? `Deliver TREK notifications over ${displayName}.` : 'Describe what your plugin does.'), type, - trek: '>=3.2.1 <4.0.0', + trek: '>=3.3.0 <4.0.0', nativeModules: false, permissions: perms, // Dependency declarations (empty by default). `requiredAddons` lists addon ids @@ -92,7 +104,7 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc // A notification channel usually targets a SELF-HOSTED service, whose hostname the // author cannot know at publish time. Declaring operatorEgress lets the admin add the // real host after install; without it the plugin only ever reaches the hosts above. - if (isChannel) manifest.operatorEgress = true; + if (operatorEgress) manifest.operatorEgress = true; // A settings-page button so the user can verify their credentials without waiting for // a real notification. Actions are USER-INITIATED, so ctx.settings.get() works inside. if (isChannel) manifest.actions = [{ key: 'testConnection', label: 'Test connection' }]; @@ -111,7 +123,9 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc label: 'Server URL', input_type: 'text', placeholder: 'https://gotify.example.com', - hint: 'Your Gotify server. Must match an entry in the manifest `egress` list.', + hint: egress.length + ? 'Your Gotify server. Must match an entry in the manifest `egress` list.' + : 'Your Gotify server. Its host must be allowed by an admin (Admin → Plugins → Allowed hosts).', required: true, scope: 'user', }, @@ -398,17 +412,20 @@ export async function interactiveScaffold(defaultDir: string, presetName?: strin }); let egress: string[] | undefined; - // A channel has to reach its service, so it always needs an egress host — the - // scaffold turns each one into a matching http:outbound: grant. + // The scaffold turns each host into a matching http:outbound: grant. Leaving it + // blank is legitimate — a plugin for a service that is only ever SELF-HOSTED has no host + // to name, so it ships as an operatorEgress plugin and the admin adds the real host. if (permissions.includes('http:outbound') || template === 'notification-channel') { const raw = await promptText({ message: template === 'notification-channel' - ? 'Which host does your notification service live on?' - : 'External hosts it may call (comma-separated)', + ? 'Which host does your notification service live on? (blank if it is always self-hosted)' + : 'External hosts it may call (comma-separated; blank if only the admin can know them)', placeholder: template === 'notification-channel' ? 'gotify.example.com' : 'api.example.com, api.other.com', - validate: (v) => ((v ?? '').split(',').map((s) => s.trim()).filter(Boolean).length ? undefined : 'list at least one host'), }); egress = raw.split(',').map((s) => s.trim()).filter(Boolean); + if (!egress.length) { + logInfo('No hosts named — the plugin will declare operatorEgress, and the TREK admin adds the real hosts after installing it.'); + } } const requiredAddons = await promptMultiselect({ @@ -426,7 +443,8 @@ export async function interactiveScaffold(defaultDir: string, presetName?: strin `location ${dest}`, `author ${author}`, `permissions ${permissions.join(', ') || '(none)'}`, - egress ? `egress ${egress.join(', ')}` : undefined, + egress?.length ? `egress ${egress.join(', ')}` : undefined, + egress && !egress.length ? 'egress (admin-supplied — operatorEgress)' : undefined, requiredAddons.length ? `addons ${requiredAddons.join(', ')}` : undefined, ].filter(Boolean).join('\n'), 'Review', diff --git a/plugin-sdk/src/cli/preflight.ts b/plugin-sdk/src/cli/preflight.ts index 9adfe0bd9..b960f968d 100644 --- a/plugin-sdk/src/cli/preflight.ts +++ b/plugin-sdk/src/cli/preflight.ts @@ -90,7 +90,10 @@ export async function preflight(entry: Entry, opts: { all?: boolean } = {}): Pro manifestPerms = Array.isArray(m.permissions) ? (m.permissions as string[]) : []; if (manifestPerms.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:'))) { const egress = Array.isArray(m.egress) ? (m.egress as string[]) : []; - if (!egress.length) fail(`${tag}: http:outbound declared but egress[] is empty`); + // Empty egress[] is legal only with operatorEgress (hosts are admin-supplied). + if (!egress.length && !mOperatorEgress) { + fail(`${tag}: http:outbound declared but egress[] is empty (set operatorEgress: true if the hosts are admin-supplied)`); + } if (egress.includes('*')) fail(`${tag}: egress[] must not contain a bare "*"`); } if (!failures.some((f) => f.startsWith(`${tag}: manifest`))) ok(`${tag}: manifest at commit matches the entry`); diff --git a/plugin-sdk/src/manifest.ts b/plugin-sdk/src/manifest.ts index d2f19f45a..ca49dc75a 100644 --- a/plugin-sdk/src/manifest.ts +++ b/plugin-sdk/src/manifest.ts @@ -120,7 +120,11 @@ export function validateManifest(raw: unknown): ValidationResult { const egress = Array.isArray(m.egress) ? m.egress.map(String) : []; const wantsOutbound = permissions.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:')); - if (wantsOutbound && egress.length === 0) errors.push('http:outbound declared but egress[] is empty'); + // Empty egress[] is legal only with operatorEgress: the hosts are admin-supplied + // post-install. Until an admin adds one the child blocks ALL outbound — not allow-all. + if (wantsOutbound && egress.length === 0 && m.operatorEgress !== true) { + errors.push('http:outbound declared but egress[] is empty (set operatorEgress: true if the hosts are admin-supplied)'); + } if (egress.includes('*')) errors.push('egress[] must not contain a bare "*"'); for (const h of egress) if (!HOST_RE.test(h)) errors.push(`invalid egress host "${h}"`); diff --git a/plugin-sdk/test/cli.test.ts b/plugin-sdk/test/cli.test.ts index c363f1128..43b0575d5 100644 --- a/plugin-sdk/test/cli.test.ts +++ b/plugin-sdk/test/cli.test.ts @@ -19,11 +19,15 @@ describe('scaffold egress (http:outbound)', () => { expect(validateManifest(m).ok).toBe(true); }); - it('without egress the same manifest is invalid — which the wizard now prevents', () => { + it('without egress it scaffolds an operatorEgress plugin (the admin names the hosts)', () => { + // The author of a plugin for an always-self-hosted service has no host to write down. + // The scaffold must NOT invent a placeholder — it declares operatorEgress instead, which + // is the one way an empty egress[] is a valid manifest. scaffold('net-plug', 'integration', tmp, { permissions: ['http:outbound'] }); const m = JSON.parse(fs.readFileSync(path.join(tmp, 'net-plug', 'trek-plugin.json'), 'utf8')); expect(m.egress).toBeUndefined(); - expect(validateManifest(m).ok).toBe(false); // http:outbound requires an egress allow-list + expect(m.operatorEgress).toBe(true); + expect(validateManifest(m).ok).toBe(true); }); it('omits egress entirely when none is given (no empty array noise)', () => { @@ -141,6 +145,19 @@ describe('notification-channel template', () => { expect(validateManifest(m).ok).toBe(true); }); + it('names no host when the author names none — bare http:outbound + operatorEgress', () => { + // The common case: a Gotify/ntfy channel targets the USER's own server. Inventing a + // `gotify.example.com` placeholder would ship a host nobody actually calls in the + // published manifest, and grant an outbound host the plugin never needs. + const m = make(); + expect(m.permissions).toContain('hook:notification-channel'); + expect(m.permissions).toContain('http:outbound'); + expect(m.permissions.some((p: string) => p.startsWith('http:outbound:'))).toBe(false); + expect('egress' in m).toBe(false); + expect(m.operatorEgress).toBe(true); + expect(validateManifest(m).ok).toBe(true); + }); + it('declares the per-user credential as a secret, required, user-scoped field', () => { const m = make(); const token = m.settings.find((s: { key: string }) => s.key === 'appToken'); diff --git a/plugin-sdk/test/sdk.test.ts b/plugin-sdk/test/sdk.test.ts index 8dcede5ce..809b1eeb9 100644 --- a/plugin-sdk/test/sdk.test.ts +++ b/plugin-sdk/test/sdk.test.ts @@ -57,6 +57,15 @@ describe('validateManifest', () => { expect(validateManifest({ ...base, permissions: ['http:outbound'] }).ok).toBe(false); expect(validateManifest({ ...base, permissions: ['http:outbound'], egress: ['api.x.com'] }).ok).toBe(true); }); + it('waives the egress requirement for an operatorEgress plugin', () => { + // A self-hosted target (Gotify, ntfy) has no host the author can name at publish + // time — the admin supplies it after install, and TREK blocks all outbound until then. + expect(validateManifest({ ...base, permissions: ['http:outbound'], operatorEgress: true }).ok).toBe(true); + // The waiver is tied to the flag: no flag, still an error. + expect(validateManifest({ ...base, permissions: ['http:outbound'], operatorEgress: false }).ok).toBe(false); + // …and the flag alone never grants reach: it still needs an http:outbound permission. + expect(validateManifest({ ...base, permissions: ['db:own'], operatorEgress: true }).ok).toBe(false); + }); it('rejects native modules and non-objects', () => { expect(validateManifest({ ...base, nativeModules: true }).ok).toBe(false); expect(validateManifest('nope').ok).toBe(false); diff --git a/server/src/nest/plugins/install/manifest.ts b/server/src/nest/plugins/install/manifest.ts index d98cfc7c3..2009070cd 100644 --- a/server/src/nest/plugins/install/manifest.ts +++ b/server/src/nest/plugins/install/manifest.ts @@ -167,8 +167,16 @@ export function parseManifest(raw: unknown): PluginManifest { if (m.operatorEgress === true && !permissions.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:'))) { throw new ManifestError('operatorEgress requires an http:outbound permission'); } - if (permissions.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:')) && egress.length === 0) { - throw new ManifestError('http:outbound declared but egress[] is empty'); + // An empty egress[] is only legal for an operatorEgress plugin: its hosts are + // admin-supplied post-install, so the manifest has nothing to declare. It is NOT + // an allow-all — the child's guard is built from the (still empty) union, so every + // outbound call is refused until an admin adds a host. + if ( + permissions.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:')) && + egress.length === 0 && + m.operatorEgress !== true + ) { + throw new ManifestError('http:outbound declared but egress[] is empty (set operatorEgress: true if the hosts are admin-supplied)'); } if (egress.includes('*')) throw new ManifestError('egress[] must not contain a bare "*"'); const badEgress = egress.find((h) => !HOST_RE.test(h)); diff --git a/server/tests/unit/plugins/operator-egress.test.ts b/server/tests/unit/plugins/operator-egress.test.ts index 9a39d04dd..ab54691f5 100644 --- a/server/tests/unit/plugins/operator-egress.test.ts +++ b/server/tests/unit/plugins/operator-egress.test.ts @@ -26,6 +26,7 @@ import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; import { parseManifest, ManifestError } from '../../../src/nest/plugins/install/manifest'; +import { makeHostAllow } from '../../../src/nest/plugins/runtime/egress-policy'; function install(id: string, operatorEgress: boolean, perms: string[] = ['http:outbound:gotify.net']) { testDb.prepare( @@ -93,6 +94,25 @@ describe('operator-supplied egress hosts', () => { expect(m.operatorEgress).toBe(true); }); + it('OEG-009 — an operatorEgress plugin may ship an EMPTY egress[]; anyone else may not', () => { + const base = { id: 'chan', name: 'Chan', version: '1.0.0', apiVersion: 1, type: 'integration', nativeModules: false }; + // A self-hosted target (Gotify, ntfy) has no host the author can name at publish time. + const m = parseManifest({ ...base, permissions: ['http:outbound'], operatorEgress: true }); + expect(m.egress).toEqual([]); + expect(m.operatorEgress).toBe(true); + // Without the flag an empty egress[] is still refused — this is what stops a plugin + // from asking for outbound while declaring no reach at all. + expect(() => parseManifest({ ...base, permissions: ['http:outbound'] })).toThrow(/egress\[\] is empty/); + }); + + it('OEG-010 — an operatorEgress plugin with no configured hosts still reaches nothing', async () => { + // It ACTIVATES (it may have useful offline features), but the child's allow-list is the + // union of its http:outbound: grants and the admin's hosts — both empty here. + install('gotify', true, ['http:outbound']); + expect(rt.operatorEgressHosts('gotify')).toEqual([]); + expect(makeHostAllow([])('gotify.mydomain.com')).toBe(false); + }); + it('OEG-007 — uninstalling drops the admin’s host consent with the plugin', async () => { install('gotify', true); await rt.setOperatorEgressHosts('gotify', ['gotify.mydomain.com']); diff --git a/wiki/Plugin-Development.md b/wiki/Plugin-Development.md index c17e39e7f..cd4fa9c52 100644 --- a/wiki/Plugin-Development.md +++ b/wiki/Plugin-Development.md @@ -748,6 +748,24 @@ Declare `operatorEgress` and the **admin** supplies the real hosts after install } ``` +If there is **no** host you can name — a plugin for a service that is *only* ever self-hosted — +`operatorEgress` is what lets you declare outbound with an **empty `egress[]`** (omit the key). +It is the one case where that is legal; without the flag, `http:outbound` with no egress is a +manifest error: + +```json +{ + "permissions": ["hook:notification-channel", "http:outbound"], + "operatorEgress": true // every host comes from the admin +} +``` + +Such a plugin still **installs and activates normally** — it may have useful non-network +features — but until an admin adds a host it reaches *nothing*: the child's allow-list is the +union of your `http:outbound:` grants and the admin's hosts, and both are empty, so every +outbound call fails with `egress: is not in the plugin's declared hosts`. An empty +`egress[]` is never an allow-all. + The admin then opens **Admin → Plugins → ⋯ → Allowed hosts** and adds `gotify.alice.net`. The runtime unions that into the child's allow-list and **re-spawns the plugin** — the egress guard is installed once at child start and a second `init` is deliberately refused, so a live child's @@ -998,11 +1016,11 @@ so a test catches a missing `scheduled`/`deleteUserData`/job before release. | `license` | string | shown in the store detail (read from the manifest, not enforced). | | `nativeModules` | boolean | must be `false`/absent — `true` is rejected. | | `permissions` | string[] | see below. | -| `egress` | string[] | allowed outbound hosts; required (non-empty, no bare `*`) when any `http:outbound` permission is present. | +| `egress` | string[] | allowed outbound hosts; required (non-empty, no bare `*`) when any `http:outbound` permission is present — **unless** `operatorEgress` is `true`, in which case it may be empty/omitted and the admin supplies the hosts. | | `capabilities.widget` | object | `{ title, slot, defaultSize }` — `slot` is `sidebar` (default), `hero`, `place-detail`, `day-detail`, or `reservation-detail`. | | `capabilities.tripPage` | object | `{ replaces?, position? }` for `trip-page` plugins — `replaces` names core planner tabs to hide while active (`transports`, `buchungen`, `listen`, `finanzplan`, `dateien`, `collab`; never `plan`), `position` is the tab's 0-based index in the bar (0–50; omitted = appended). | | `actions` | array | Buttons on the plugin's own settings page — `{ key, label, hint?, danger? }` (max 8). Implement each as `actions[key](ctx)` on the definition. **User-initiated**, so `ctx.settings.get()` returns the clicking user's value. See [Settings-page actions](#settings-page-actions). | -| `operatorEgress` | boolean | The plugin talks to a **self-hosted** service whose hostname only the operator knows. The admin adds the real hosts after install (Admin → Plugins → Allowed hosts) and the runtime unions them into the egress allow-list. Requires an `http:outbound` permission. See [Operator-supplied egress hosts](#operator-supplied-egress-hosts-operatoregress). | +| `operatorEgress` | boolean | The plugin talks to a **self-hosted** service whose hostname only the operator knows. The admin adds the real hosts after install (Admin → Plugins → Allowed hosts) and the runtime unions them into the egress allow-list. Requires an `http:outbound` permission, and is the only way to declare one with an empty `egress[]`. See [Operator-supplied egress hosts](#operator-supplied-egress-hosts-operatoregress). | | `capabilities.notificationChannel` | object | `{ title?, events? }` for a plugin implementing the `notificationChannel` hook — `title` names the column in the notification preferences matrix (default: the plugin's `name`), `events` **narrows** which events the channel carries (default: every non-admin event; admin-scoped events are never deliverable). Requires the `hook:notification-channel` permission. See [Notification channels](#notification-channels). | | `capabilities.provides` | string[] | function names this plugin exposes to its dependents via `ctx.plugins.call` (see [Talking to other plugins](#talking-to-other-plugins)). | | `capabilities.emits` | string[] | event names this plugin publishes to its dependents via `ctx.events.emit`. | diff --git a/wiki/Plugin-Permissions.md b/wiki/Plugin-Permissions.md index ed9b50416..b64ef8ffa 100644 --- a/wiki/Plugin-Permissions.md +++ b/wiki/Plugin-Permissions.md @@ -67,7 +67,7 @@ ungranted capability is physically unreachable**, not just disallowed. See | `hook:trip-card-provider` | Add small **badges** to the dashboard trip cards via the `hooks.tripCardProvider` provider hook | Implement `TripCardProvider` in `hooks` — `getCards(tripIds, ctx)` is called ONCE with all the trip cards currently on the user's dashboard (each already access-checked for the acting user), returns `{tripId, id, label, value?, icon?, tone?, url?}[]`. **Declarative only** — plugin JS never runs on the dashboard. The host String-coerces + length-caps every field, enum-whitelists the tone, allowlists the url (http/https/mailto), caps the count (≤40/plugin) and drops any badge whose `tripId` the dashboard didn't ask about; a failing provider is skipped. Exposed at `GET /api/trip-card-contributions?tripIds=…`. | | `hook:notification-channel` | Register a new **notification channel** (Gotify, Pushover, …) via the `hooks.notificationChannel` provider hook | Implement `NotificationChannel` in `hooks` — `send(msg, config, ctx)` receives a notification TREK has **already rendered** into the recipient's language (`{event, title, body, url?, tripName?}`) plus that recipient's own decrypted `scope:'user'` settings as `config`. Unlike every other hook this one is **host-initiated for an arbitrary recipient**, so it runs **userless**: `ctx.settings.get()` returns `undefined` and trip reads are refused — the recipient's credentials arrive as `config` precisely so the plugin never gains the right to read anything *as* them. Optional `test(config, ctx)` backs the "Send test" button. Declare the channel with `capabilities.notificationChannel: { title?, events? }`; `title` names the column in the preferences matrix (default: the plugin's name) and `events` may **narrow** which events it carries (default: every non-admin event — admin-scoped events are never deliverable to a plugin). Throw on failure: the host logs it and isolates it, so a dead channel can't stop the others. | | `hook:user-data` | Honour GDPR **data-subject rights** — erase and export the data the plugin stores about a user — via the `deleteUserData` / `exportUserData` handlers | Implement `deleteUserData({userId}, ctx)` and/or `exportUserData({userId}, ctx)` on the plugin definition (not on `ctx`). Both are **userless** (no acting user; the plugin only learns the `userId` and touches its OWN db). When a TREK account is deleted, the host queues an erasure for every plugin holding this grant and retries it **durably** until the plugin ACKs — even across restarts — so implement `deleteUserData` idempotently. `exportUserData` returns a JSON-serialisable value the host aggregates for an admin at `GET /api/admin/plugins/user-data/:userId/export`. | -| `http:outbound` / `http:outbound:` | Make outbound network requests | **Requires** a non-empty `egress[]`. Only a **per-host** `http:outbound:` actually opens a host at runtime — see below. | +| `http:outbound` / `http:outbound:` | Make outbound network requests | **Requires** a non-empty `egress[]` — unless the manifest sets `operatorEgress: true`. Only a **per-host** `http:outbound:` actually opens a host at runtime — see below. | ## Outbound network — `http:outbound` vs `http:outbound:` @@ -86,7 +86,8 @@ Two independent guards restrict a plugin's network, and **both are built from th - Only the permissions above are accepted; an unknown string fails validation. - If **any** `http:outbound` permission (bare or per-host) is declared, `egress[]` - must be **non-empty**. + must be **non-empty** — *unless* the manifest sets `operatorEgress: true`, whose + hosts arrive from the admin after install (see below). - `egress[]` may not contain a bare `*`. ### `operatorEgress` — hosts only the operator knows @@ -105,6 +106,9 @@ It is not an escape hatch: - Added hosts are validated exactly like manifest egress (no bare `*`, no whole-TLD wildcard, no scheme), and are dropped when the plugin is uninstalled. - It requires an `http:outbound` permission — the manifest rejects it otherwise. +- It is the **only** way to declare outbound with an empty `egress[]` (a plugin whose target + is *always* self-hosted). Such a plugin activates, but reaches nothing until an admin adds + a host — an empty allow-list blocks everything, it never means "any host". A LAN/loopback host additionally needs `TREK_PLUGIN_ALLOW_PRIVATE_EGRESS=on`, which relaxes private-address egress for **every** installed plugin.