diff --git a/src/shared/plugins/plugin-kill-list.test.ts b/src/shared/plugins/plugin-kill-list.test.ts new file mode 100644 index 00000000000..af3036f041a --- /dev/null +++ b/src/shared/plugins/plugin-kill-list.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { + PLUGIN_KILL_LIST_ENTRY_LIMIT, + findKilledPlugin, + killedPluginKeys, + pluginKillListSchema +} from './plugin-kill-list' + +function entry(pluginKey = 'community.unsafe'): Record { + return { + pluginKey, + reason: 'Known malicious release', + advisoryUrl: 'https://orca.example/security/unsafe' + } +} + +describe('pluginKillListSchema', () => { + it('parses a strict versioned revocation document', () => { + const parsed = pluginKillListSchema.parse({ + version: 1, + generatedAt: '2026-07-12T20:00:00Z', + plugins: [entry()] + }) + + expect(findKilledPlugin(parsed, 'community.unsafe')).toEqual(entry()) + expect(killedPluginKeys(parsed)).toEqual(new Set(['community.unsafe'])) + }) + + it.each([ + { + version: 1, + generatedAt: '2026-07-12T20:00:00Z', + plugins: [], + unexpected: true + }, + { + version: 1, + generatedAt: '2026-07-12T20:00:00Z', + plugins: [{ ...entry(), unexpected: true }] + }, + { + version: 1, + generatedAt: 'not-a-date', + plugins: [] + }, + { + version: 1, + generatedAt: '2026-07-12T20:00:00Z', + plugins: [{ ...entry(), pluginKey: 'unsafe' }] + }, + { + version: 1, + generatedAt: '2026-07-12T20:00:00Z', + plugins: [{ ...entry(), advisoryUrl: 'http://orca.example/advisory' }] + } + ])('rejects malformed or untrusted fields', (killList) => { + expect(pluginKillListSchema.safeParse(killList).success).toBe(false) + }) + + it('rejects duplicate killed plugin identities', () => { + const parsed = pluginKillListSchema.safeParse({ + version: 1, + generatedAt: '2026-07-12T20:00:00Z', + plugins: [entry(), entry()] + }) + + expect(parsed.success).toBe(false) + if (!parsed.success) { + expect(parsed.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ message: 'duplicate killed plugin: community.unsafe' }) + ]) + ) + } + }) + + it('caps the revocation document size by entry count', () => { + const plugins = Array.from({ length: PLUGIN_KILL_LIST_ENTRY_LIMIT + 1 }, (_, index) => + entry(`publisher.plugin-${index}`) + ) + expect( + pluginKillListSchema.safeParse({ + version: 1, + generatedAt: '2026-07-12T20:00:00Z', + plugins + }).success + ).toBe(false) + }) +}) diff --git a/src/shared/plugins/plugin-kill-list.ts b/src/shared/plugins/plugin-kill-list.ts new file mode 100644 index 00000000000..5200cf99277 --- /dev/null +++ b/src/shared/plugins/plugin-kill-list.ts @@ -0,0 +1,50 @@ +import { z } from 'zod' +import { isQualifiedPluginKey } from './plugin-manifest' + +export const PLUGIN_KILL_LIST_ENTRY_LIMIT = 4_096 + +const advisoryUrlSchema = z + .string() + .url() + .max(2_048) + .refine((value) => new URL(value).protocol === 'https:', 'advisory URL must use HTTPS') + +export const pluginKillListEntrySchema = z.strictObject({ + pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key'), + reason: z.string().min(1).max(1_024), + advisoryUrl: advisoryUrlSchema.optional() +}) + +export const pluginKillListSchema = z + .strictObject({ + version: z.literal(1), + generatedAt: z.string().datetime({ offset: true }), + plugins: z.array(pluginKillListEntrySchema).max(PLUGIN_KILL_LIST_ENTRY_LIMIT) + }) + .superRefine((killList, context) => { + const seen = new Set() + for (const [index, plugin] of killList.plugins.entries()) { + if (seen.has(plugin.pluginKey)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['plugins', index, 'pluginKey'], + message: `duplicate killed plugin: ${plugin.pluginKey}` + }) + } + seen.add(plugin.pluginKey) + } + }) + +export type PluginKillList = z.infer +export type PluginKillListEntry = z.infer + +export function killedPluginKeys(killList: PluginKillList): ReadonlySet { + return new Set(killList.plugins.map((plugin) => plugin.pluginKey)) +} + +export function findKilledPlugin( + killList: PluginKillList, + pluginKey: string +): PluginKillListEntry | null { + return killList.plugins.find((plugin) => plugin.pluginKey === pluginKey) ?? null +} diff --git a/src/shared/plugins/plugin-marketplace.test.ts b/src/shared/plugins/plugin-marketplace.test.ts new file mode 100644 index 00000000000..29707e8e056 --- /dev/null +++ b/src/shared/plugins/plugin-marketplace.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest' +import { + OFFICIAL_MARKETPLACE_REPOSITORY, + PLUGIN_MARKETPLACE_CATEGORY_LIMIT, + PLUGIN_MARKETPLACE_ENTRY_LIMIT, + isOfficialMarketplaceGitSource, + isOfficialOrganizationGitSource, + isOfficialPluginIdentity, + isReservedPluginIdentity, + parseGitRepositoryIdentity, + pluginMarketplaceSchema, + pluginMarketplaceTrustMetadataSchema +} from './plugin-marketplace' + +function listing(id = 'community.nord'): Record { + return { + id, + source: { kind: 'git', url: 'https://github.com/community/nord.git', ref: 'v1.0.0' }, + description: 'A theme pack', + categories: ['themes'] + } +} + +describe('pluginMarketplaceSchema', () => { + it('parses a bounded, pinned Git marketplace index', () => { + expect( + pluginMarketplaceSchema.parse({ + name: 'Community plugins', + owner: 'community', + plugins: [listing()] + }) + ).toEqual({ + name: 'Community plugins', + owner: 'community', + plugins: [listing()] + }) + }) + + it.each([ + ['root', { name: 'Plugins', owner: 'team', plugins: [], extra: true }], + ['entry', { name: 'Plugins', owner: 'team', plugins: [{ ...listing(), official: true }] }], + [ + 'source', + { + name: 'Plugins', + owner: 'team', + plugins: [ + { + ...listing(), + source: { + kind: 'git', + url: 'https://github.com/community/nord.git', + ref: 'main', + depth: 1 + } + } + ] + } + ] + ])('rejects unknown keys at the %s boundary', (_boundary, marketplace) => { + expect(pluginMarketplaceSchema.safeParse(marketplace).success).toBe(false) + }) + + it.each([ + ['an unqualified id', { ...listing(), id: 'nord' }], + [ + 'a missing ref', + { + ...listing(), + source: { kind: 'git', url: 'https://github.com/community/nord.git', ref: '' } + } + ], + [ + 'an executable Git transport', + { + ...listing(), + source: { kind: 'git', url: 'ext::sh -c exploit', ref: 'main' } + } + ], + ['a duplicate category', { ...listing(), categories: ['themes', 'themes'] }], + [ + 'too many categories', + { + ...listing(), + categories: Array.from( + { length: PLUGIN_MARKETPLACE_CATEGORY_LIMIT + 1 }, + (_, index) => `category-${index}` + ) + } + ] + ])('rejects %s', (_label, entry) => { + expect( + pluginMarketplaceSchema.safeParse({ name: 'Plugins', owner: 'team', plugins: [entry] }) + .success + ).toBe(false) + }) + + it('rejects duplicate plugin identities', () => { + const parsed = pluginMarketplaceSchema.safeParse({ + name: 'Plugins', + owner: 'team', + plugins: [listing(), listing()] + }) + + expect(parsed.success).toBe(false) + if (!parsed.success) { + expect(parsed.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ message: 'duplicate plugin id: community.nord' }) + ]) + ) + } + }) + + it('caps the marketplace entry count', () => { + const plugins = Array.from({ length: PLUGIN_MARKETPLACE_ENTRY_LIMIT + 1 }, (_, index) => + listing(`publisher.plugin-${index}`) + ) + expect( + pluginMarketplaceSchema.safeParse({ name: 'Plugins', owner: 'team', plugins }).success + ).toBe(false) + }) +}) + +describe('marketplace provenance contracts', () => { + it.each([ + ['stablyai.orca-skills', true, true], + ['stablyai.skills', true, false], + ['community.orca-skills', true, false], + ['community.skills', false, false], + ['invalid', false, false] + ])('classifies %s', (pluginKey, reserved, official) => { + expect(isReservedPluginIdentity(pluginKey)).toBe(reserved) + expect(isOfficialPluginIdentity(pluginKey)).toBe(official) + }) + + it.each([ + 'https://github.com/stablyai/orca-skills.git', + 'ssh://git@github.com/stablyai/orca-skills.git', + 'git@github.com:stablyai/orca-skills.git' + ])('accepts official organization source %s', (source) => { + expect(isOfficialOrganizationGitSource(source)).toBe(true) + }) + + it('does not trust lookalike organizations or hosts', () => { + expect(isOfficialOrganizationGitSource('https://github.com/stablyai-fakes/orca-skills')).toBe( + false + ) + expect(isOfficialOrganizationGitSource('https://gitlab.com/stablyai/orca-skills')).toBe(false) + }) + + it('recognizes only the canonical official marketplace repository', () => { + expect( + isOfficialMarketplaceGitSource( + `git@github.com:stablyai/${OFFICIAL_MARKETPLACE_REPOSITORY}.git` + ) + ).toBe(true) + expect(isOfficialMarketplaceGitSource('git@github.com:stablyai/plugins.git')).toBe(false) + }) + + it('parses nested repository paths without confusing the repository name', () => { + expect(parseGitRepositoryIdentity('https://gitlab.com/team/subgroup/plugin.git')).toEqual({ + host: 'gitlab.com', + owner: 'team', + repository: 'plugin' + }) + }) + + it('prevents an untrusted listing from self-awarding trust metadata', () => { + expect(pluginMarketplaceTrustMetadataSchema.parse({ official: true, bundled: true })).toEqual({ + official: true, + bundled: true + }) + expect( + pluginMarketplaceTrustMetadataSchema.safeParse({ official: false, bundled: true }).success + ).toBe(false) + }) +}) diff --git a/src/shared/plugins/plugin-marketplace.ts b/src/shared/plugins/plugin-marketplace.ts new file mode 100644 index 00000000000..2b4798b9fe0 --- /dev/null +++ b/src/shared/plugins/plugin-marketplace.ts @@ -0,0 +1,184 @@ +import { z } from 'zod' +import { isAllowedPluginGitUrl } from './plugin-install-lockfile' +import { isQualifiedPluginKey } from './plugin-manifest' + +export const PLUGIN_MARKETPLACE_FILENAME = 'orca-marketplace.json' +export const PLUGIN_MARKETPLACE_ENTRY_LIMIT = 2_048 +export const PLUGIN_MARKETPLACE_CATEGORY_LIMIT = 16 + +export const OFFICIAL_PLUGIN_PUBLISHER = 'stablyai' +export const OFFICIAL_PLUGIN_ID_PREFIX = 'orca-' +export const OFFICIAL_MARKETPLACE_OWNER = 'stablyai' +export const OFFICIAL_MARKETPLACE_REPOSITORY = 'orca-plugins' + +const marketplaceOwnerSchema = z + .string() + .min(1) + .max(128) + .regex(/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/, 'invalid marketplace owner') + +const marketplaceCategorySchema = z + .string() + .min(1) + .max(64) + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'categories must be lowercase slugs') + +export const pluginMarketplaceGitSourceSchema = z.strictObject({ + kind: z.literal('git'), + url: z + .string() + .trim() + .min(1) + .max(32 * 1024) + .refine(isAllowedPluginGitUrl, 'git URL must use HTTPS or SSH'), + // Why: marketplace installs must first resolve a named ref to an exact + // commit; an omitted remote default would make the listing irreproducible. + ref: z.string().trim().min(1).max(4_096) +}) + +export const pluginMarketplaceEntrySchema = z + .strictObject({ + /** Canonical `.` identity expected in the source manifest. */ + id: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key'), + source: pluginMarketplaceGitSourceSchema, + description: z.string().min(1).max(4_096).optional(), + categories: z + .array(marketplaceCategorySchema) + .max(PLUGIN_MARKETPLACE_CATEGORY_LIMIT) + .default([]) + }) + .superRefine((entry, context) => { + const seen = new Set() + for (const [index, category] of entry.categories.entries()) { + if (seen.has(category)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['categories', index], + message: `duplicate category: ${category}` + }) + } + seen.add(category) + } + }) + +export const pluginMarketplaceSchema = z + .strictObject({ + name: z.string().min(1).max(256), + owner: marketplaceOwnerSchema, + plugins: z.array(pluginMarketplaceEntrySchema).max(PLUGIN_MARKETPLACE_ENTRY_LIMIT) + }) + .superRefine((marketplace, context) => { + const seen = new Set() + for (const [index, plugin] of marketplace.plugins.entries()) { + if (seen.has(plugin.id)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['plugins', index, 'id'], + message: `duplicate plugin id: ${plugin.id}` + }) + } + seen.add(plugin.id) + } + }) + +/** Host-derived trust metadata. Marketplace JSON cannot self-award either bit. */ +export const pluginMarketplaceTrustMetadataSchema = z + .strictObject({ + official: z.boolean(), + bundled: z.boolean() + }) + .refine((metadata) => !metadata.bundled || metadata.official, { + message: 'bundled plugins must be official', + path: ['bundled'] + }) + +export type PluginMarketplace = z.infer +export type PluginMarketplaceEntry = z.infer +export type PluginMarketplaceGitSource = z.infer +export type PluginMarketplaceTrustMetadata = z.infer + +export function splitQualifiedPluginKey(pluginKey: string): { + publisher: string + id: string +} | null { + if (!isQualifiedPluginKey(pluginKey)) { + return null + } + const separator = pluginKey.indexOf('.') + return { + publisher: pluginKey.slice(0, separator), + id: pluginKey.slice(separator + 1) + } +} + +export function isReservedPluginIdentity(pluginKey: string): boolean { + const identity = splitQualifiedPluginKey(pluginKey) + return ( + identity !== null && + (identity.publisher === OFFICIAL_PLUGIN_PUBLISHER || + identity.id.startsWith(OFFICIAL_PLUGIN_ID_PREFIX)) + ) +} + +export function isOfficialPluginIdentity(pluginKey: string): boolean { + const identity = splitQualifiedPluginKey(pluginKey) + return ( + identity !== null && + identity.publisher === OFFICIAL_PLUGIN_PUBLISHER && + identity.id.startsWith(OFFICIAL_PLUGIN_ID_PREFIX) + ) +} + +type GitRepositoryIdentity = { + host: string + owner: string + repository: string +} + +/** Extracts the host and first owner segment from the HTTPS/SSH Git forms the + * installer accepts. It is intentionally only a provenance parser. */ +export function parseGitRepositoryIdentity(url: string): GitRepositoryIdentity | null { + const trimmed = url.trim() + const scp = /^[^\s@/:]+@([^\s:]+):(.+)$/.exec(trimmed) + if (scp) { + return repositoryIdentity(scp[1]!, scp[2]!) + } + try { + const parsed = new URL(trimmed) + if (parsed.protocol !== 'https:' && parsed.protocol !== 'ssh:') { + return null + } + return repositoryIdentity(parsed.hostname, parsed.pathname) + } catch { + return null + } +} + +function repositoryIdentity(host: string, repositoryPath: string): GitRepositoryIdentity | null { + const segments = repositoryPath + .replace(/^\/+|\/+$/g, '') + .split('/') + .filter(Boolean) + if (segments.length < 2) { + return null + } + const repository = segments.at(-1)!.replace(/\.git$/i, '') + if (!repository) { + return null + } + return { host: host.toLowerCase(), owner: segments[0]!, repository } +} + +export function isOfficialOrganizationGitSource(url: string): boolean { + const source = parseGitRepositoryIdentity(url) + return source?.host === 'github.com' && source.owner.toLowerCase() === OFFICIAL_PLUGIN_PUBLISHER +} + +export function isOfficialMarketplaceGitSource(url: string): boolean { + const source = parseGitRepositoryIdentity(url) + return ( + source?.host === 'github.com' && + source.owner.toLowerCase() === OFFICIAL_MARKETPLACE_OWNER && + source.repository.toLowerCase() === OFFICIAL_MARKETPLACE_REPOSITORY + ) +}