From 845e79bed48ae479e56313b9678545d09239a546 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Sat, 19 Sep 2026 11:28:54 +0200 Subject: [PATCH] feat(catalog): describe required and optional plugin permissions Signed-off-by: David Viejo --- registry/README.md | 24 ++++++++++++++++++++++++ scripts/catalog.test.ts | 19 ++++++++++++++++++- scripts/catalog.ts | 19 +++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/registry/README.md b/registry/README.md index 4f0e98f..f868dec 100644 --- a/registry/README.md +++ b/registry/README.md @@ -34,3 +34,27 @@ The dependency-install phase has network access to download locked packages; only the subsequent compile phase is offline. Lifecycle scripts are disabled during installation. This is build validation, not full network isolation or a malware review. + +## Permission requirements + +Plugin authors declare `temps.permissions` in the plugin's `package.json` at the +same commit as its source. These entries describe the runtime manifest's requested +host permissions; they never grant access themselves. + +```json +"permissions": [ + { "permission": "events_read", "required": false, "reason": "Enables crawls after deployments. Manual crawls work without this permission." } +] +``` + +Use `required: true` only when the plugin's core functionality cannot work without +that permission. Optional entries explain which feature is unavailable if denied. +Administrators explicitly approve every grant, including required permissions. +The install UI asks for required approvals before proceeding; runtime host +permission checks remain authoritative and grants can be revoked later. + +Omission means requirements are unknown (legacy metadata), not that no access is +needed. Use an explicit empty array for a plugin requesting no host permissions. +Only the seven host permissions are accepted; entries must be unique, include a +boolean `required` and a nonempty explanation of at most 500 characters. +Catalog validation checks metadata shape, not whether source code tells the truth. diff --git a/scripts/catalog.test.ts b/scripts/catalog.test.ts index 7199353..80c5f8a 100644 --- a/scripts/catalog.test.ts +++ b/scripts/catalog.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { parseListing, resolvePlugin } from "./catalog"; +import { parseListing, resolvePlugin, parsePermissions } from "./catalog"; const sha = "a".repeat(40); const listing = { repo: "gotempsh/temps-plugin-template", categories: ["developer-tools"] }; @@ -39,3 +39,20 @@ describe("catalog submissions", () => { await expect(resolvePlugin("my-plugin", listing, fixture({ ...pkg, temps: { ...pkg.temps, logo: "../secret" } }))).rejects.toThrow("invalid asset path"); }); }); + + +test('permission metadata preserves legacy unknown, explicit none and author requirements', async () => { + expect(parsePermissions(undefined, 'demo')).toBeUndefined(); + expect(parsePermissions([], 'demo')).toEqual([]); + const permissions = [{permission:'events_read', required:false, reason:' Enables deployment crawls. '}]; + const result = await resolvePlugin('my-plugin', listing, fixture({...pkg, temps:{...pkg.temps, permissions}})); + expect(result.permissions).toEqual([{permission:'events_read', required:false, reason:'Enables deployment crawls.'}]); + expect(parsePermissions([{permission:'projects_read', required:true, reason:'Lists projects.'}], 'demo')?.[0].required).toBe(true); +}); + +test('permission metadata rejects malformed, duplicate, unknown and oversized requirements', () => { + const valid = {permission:'events_read', required:false, reason:'Deployment crawls.'}; + for (const value of [null, {}, [null], [valid,valid], [{...valid,permission:'secrets_read'}], [{...valid,required:'yes'}], [{...valid,reason:' '}], [{...valid,reason:'x'.repeat(501)}], Array(8).fill(valid), [{...valid,extra:true}]]) { + expect(() => parsePermissions(value, 'demo')).toThrow(); + } +}); diff --git a/scripts/catalog.ts b/scripts/catalog.ts index 3065a1a..1a163ae 100644 --- a/scripts/catalog.ts +++ b/scripts/catalog.ts @@ -9,7 +9,24 @@ const namePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const shaPattern = /^[a-f0-9]{40}$/; export type Listing = { repo: string; categories: string[] }; +export type CatalogPermission = { permission: string; required: boolean; reason: string }; +const permissionNames = new Set(['ai_generate', 'projects_read', 'environments_read', 'deployments_read', 'events_read', 'api_read', 'api_write']); + +export function parsePermissions(value: unknown, context: string): CatalogPermission[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value) || value.length > permissionNames.size) throw new Error(`${context}: permissions must be an array of at most seven entries`); + const seen = new Set(); + return value.map(entry => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) throw new Error(`${context}: invalid permission entry`); + const { permission, required, reason } = entry; + if (Object.keys(entry).sort().join(',') !== 'permission,reason,required' || !permissionNames.has(permission) || seen.has(permission) || typeof required !== 'boolean' || typeof reason !== 'string' || !reason.trim() || Array.from(reason).length > 500) throw new Error(`${context}: invalid or duplicate permission requirement`); + seen.add(permission); + return { permission, required, reason: reason.trim() }; + }); +} + export type CatalogPlugin = { + permissions?: CatalogPermission[]; name: string; title: string; summary: string; description: string; author: string; category: string; repository: string; docsUrl: string | null; logoUrl: string | null; screenshots: { url: string; alt: string; caption: string }[]; @@ -68,6 +85,7 @@ export async function resolvePlugin(name: string, listing: Listing, fetcher: typ const version = requiredString(pkg.version, `${listing.repo} version`); const platforms = Array.isArray(manifest.platforms) ? manifest.platforms : []; if (platforms.some((p: unknown) => typeof p !== "string" || !/^[a-z0-9_-]+$/.test(p))) throw new Error(`${listing.repo}: invalid platforms`); + const permissions = parsePermissions(manifest.permissions, `${listing.repo} temps.permissions`); const rawBase = `https://raw.githubusercontent.com/${listing.repo}/${sha}`; const asset = (path: unknown): string | null => { if (path == null) return null; @@ -77,6 +95,7 @@ export async function resolvePlugin(name: string, listing: Listing, fetcher: typ const shots = manifest.screenshots ?? []; if (!Array.isArray(shots) || shots.length > 8) throw new Error(`${listing.repo}: invalid screenshots`); return { + ...(permissions !== undefined ? { permissions } : {}), name, title, summary, description, author: requiredString(author ?? repo.owner?.login, `${listing.repo} author`), category: categoryLabels[listing.categories[0]], repository: `https://github.com/${listing.repo}`, docsUrl: typeof manifest.docsUrl === "string" && /^https:\/\//.test(manifest.docsUrl) ? manifest.docsUrl : null,