mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 08:02:54 +00:00
feat(plugins): define marketplace trust contracts
This commit is contained in:
@@ -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<string, unknown> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<string>()
|
||||
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<typeof pluginKillListSchema>
|
||||
export type PluginKillListEntry = z.infer<typeof pluginKillListEntrySchema>
|
||||
|
||||
export function killedPluginKeys(killList: PluginKillList): ReadonlySet<string> {
|
||||
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
|
||||
}
|
||||
@@ -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<string, unknown> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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 `<publisher>.<id>` 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<string>()
|
||||
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<string>()
|
||||
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<typeof pluginMarketplaceSchema>
|
||||
export type PluginMarketplaceEntry = z.infer<typeof pluginMarketplaceEntrySchema>
|
||||
export type PluginMarketplaceGitSource = z.infer<typeof pluginMarketplaceGitSourceSchema>
|
||||
export type PluginMarketplaceTrustMetadata = z.infer<typeof pluginMarketplaceTrustMetadataSchema>
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user