Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions registry/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
19 changes: 18 additions & 1 deletion scripts/catalog.test.ts
Original file line number Diff line number Diff line change
@@ -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"] };
Expand Down Expand Up @@ -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();
}
});
19 changes: 19 additions & 0 deletions scripts/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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 }[];
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down
Loading