feat(coding): add marketplace package trust primitives
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
isValidSemVer,
|
||||
} from './release-descriptor';
|
||||
import {
|
||||
AGENT_PLUGINS_SCHEMA_URL,
|
||||
BUNDLED_CODING_PLUGIN_ADAPTER_IDS,
|
||||
@@ -13,6 +16,10 @@ import {
|
||||
DATA_SERVICE_TOOL_NAMES,
|
||||
type AgentPluginsRootManifest,
|
||||
type CodingPluginDefinition,
|
||||
type CodingPluginAcquisitionMode,
|
||||
type CodingPluginExecutionMode,
|
||||
type CodingPluginPackageProvenance,
|
||||
type CodingPluginRuntimeKind,
|
||||
type CodingPluginSkillDefinition,
|
||||
type CodingPluginToolDefinition,
|
||||
type PluginToolMutation,
|
||||
@@ -33,7 +40,6 @@ const SKILL_ID_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/u;
|
||||
const CAPABILITY_ID_PATTERN = /^[a-z][a-z0-9.-]{0,63}$/u;
|
||||
const OPERATION_ID_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/u;
|
||||
const TOOL_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9._:-]{0,63}$/u;
|
||||
const SEMVER_PATTERN = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u;
|
||||
const MAX_DISPLAY_TEXT = 256;
|
||||
|
||||
const UNSUPPORTED_COMPONENT_KEYS = new Set([
|
||||
@@ -103,6 +109,64 @@ const TOOL_KEYS = new Set([
|
||||
]);
|
||||
const SURFACE_KEYS = new Set(['projectSettings', 'previewRuntime']);
|
||||
|
||||
const V2_CAPABILITY_KEYS = new Set([
|
||||
'schemaVersion',
|
||||
'pluginId',
|
||||
'contractVersion',
|
||||
'scope',
|
||||
'runtime',
|
||||
'skills',
|
||||
'tools',
|
||||
]);
|
||||
const V2_RUNTIME_KEYS = new Set(['kind', 'protocol']);
|
||||
const V2_SKILL_KEYS = new Set(['id', 'entry', 'grants']);
|
||||
const V2_TOOL_KEYS = new Set([
|
||||
'name',
|
||||
'label',
|
||||
'description',
|
||||
'capabilityId',
|
||||
'operation',
|
||||
'roles',
|
||||
'mutation',
|
||||
'projectWriteLease',
|
||||
'permissions',
|
||||
'executionMode',
|
||||
'inputSchema',
|
||||
'outputSchema',
|
||||
]);
|
||||
const HOSTED_PERMISSION_PATTERN = /^hosted\.[a-z][a-z0-9._-]{0,127}$/u;
|
||||
const V2_PROPERTY_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/u;
|
||||
const V2_SCHEMA_TYPES = new Set(['array', 'boolean', 'integer', 'number', 'object', 'string']);
|
||||
const V2_SCHEMA_KEYS = new Set([
|
||||
'type',
|
||||
'additionalProperties',
|
||||
'required',
|
||||
'properties',
|
||||
'items',
|
||||
'minLength',
|
||||
'maxLength',
|
||||
'minimum',
|
||||
'maximum',
|
||||
'maxItems',
|
||||
'const',
|
||||
]);
|
||||
|
||||
export interface CodingPluginManifestParseOptions {
|
||||
packageRoot?: string;
|
||||
rootManifestPath?: string;
|
||||
capabilityManifestPath?: string;
|
||||
/** Metadata supplied by the trusted bundle/package-store owner. */
|
||||
runtimeKind?: CodingPluginRuntimeKind;
|
||||
acquisitionMode?: CodingPluginAcquisitionMode;
|
||||
releaseId?: string | null;
|
||||
provenance?: CodingPluginPackageProvenance;
|
||||
}
|
||||
|
||||
export type CodingPluginLoadOptions = Pick<
|
||||
CodingPluginManifestParseOptions,
|
||||
'runtimeKind' | 'acquisitionMode' | 'releaseId' | 'provenance'
|
||||
>;
|
||||
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
export class CodingPluginManifestError extends Error {
|
||||
@@ -248,6 +312,41 @@ function normalizeCandidatePath(
|
||||
return relative.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function normalizeV2CandidatePath(
|
||||
packageRoot: string,
|
||||
baseDirectory: string,
|
||||
candidate: unknown,
|
||||
filePath: string,
|
||||
field: string,
|
||||
): string {
|
||||
if (typeof candidate !== 'string') fail(filePath, field, 'expected a relative path');
|
||||
const portable = candidate.replaceAll('\\', '/');
|
||||
if (portable !== candidate || portable.split('/').some((segment) => segment === '')) {
|
||||
fail(filePath, field, 'path must use canonical relative separators');
|
||||
}
|
||||
if (portable.split('/').some((segment) => segment === '.')) {
|
||||
fail(filePath, field, 'path must not contain dot segments');
|
||||
}
|
||||
return normalizeCandidatePath(packageRoot, baseDirectory, candidate, filePath, field);
|
||||
}
|
||||
|
||||
function normalizeCapabilityManifestPath(
|
||||
packageRoot: string,
|
||||
candidate: unknown,
|
||||
filePath: string,
|
||||
field: string,
|
||||
): string {
|
||||
if (typeof candidate !== 'string') fail(filePath, field, 'expected a relative path');
|
||||
const portable = candidate.replaceAll('\\', '/');
|
||||
const segments = portable.split('/');
|
||||
if (portable !== candidate || segments.some((segment, index) => (
|
||||
segment === '' || segment === '..' || (segment === '.' && index !== 0)
|
||||
))) {
|
||||
fail(filePath, field, 'path must use the canonical capability manifest location');
|
||||
}
|
||||
return normalizeCandidatePath(packageRoot, packageRoot, candidate, filePath, field);
|
||||
}
|
||||
|
||||
function validateSchemaNode(value: unknown, filePath: string, field: string, root = false): void {
|
||||
const schema = assertRecord(value, filePath, field);
|
||||
const allowed = root
|
||||
@@ -305,6 +404,112 @@ function validateSchemaNode(value: unknown, filePath: string, field: string, roo
|
||||
}
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown, filePath: string, field: string): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
fail(filePath, field, 'must be a finite number');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function boundedInteger(value: unknown, filePath: string, field: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
fail(filePath, field, 'must be a non-negative safe integer');
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema-v2 deliberately accepts a small data-only subset. This validator is
|
||||
* separate from the legacy schema-1 validator because the bundled Data Service
|
||||
* contract predates the v2 requirement that strings, arrays, and numbers carry
|
||||
* explicit bounds.
|
||||
*/
|
||||
function validateV2SchemaNode(
|
||||
value: unknown,
|
||||
filePath: string,
|
||||
field: string,
|
||||
depth = 1,
|
||||
): void {
|
||||
if (depth > 4) fail(filePath, field, 'schema exceeds the maximum depth of 4');
|
||||
const schema = assertRecord(value, filePath, field);
|
||||
assertExactKeys(schema, V2_SCHEMA_KEYS, filePath, field);
|
||||
if (typeof schema.type !== 'string' || !V2_SCHEMA_TYPES.has(schema.type)) {
|
||||
fail(filePath, `${field}.type`, 'unsupported bounded schema type');
|
||||
}
|
||||
const type = schema.type;
|
||||
if ('const' in schema && (type !== 'boolean' || typeof schema.const !== 'boolean')) {
|
||||
fail(filePath, `${field}.const`, 'only boolean constants are supported');
|
||||
}
|
||||
if ('additionalProperties' in schema) {
|
||||
if (type !== 'object' || schema.additionalProperties !== false) {
|
||||
fail(filePath, `${field}.additionalProperties`, 'only false is supported on object schemas');
|
||||
}
|
||||
}
|
||||
if ('required' in schema && !Array.isArray(schema.required)) {
|
||||
fail(filePath, `${field}.required`, 'must be an array');
|
||||
}
|
||||
if (type === 'object') {
|
||||
if (schema.additionalProperties !== false) {
|
||||
fail(filePath, `${field}.additionalProperties`, 'object schemas must close additional properties');
|
||||
}
|
||||
if (!('required' in schema)) {
|
||||
fail(filePath, `${field}.required`, 'object schemas must declare required properties');
|
||||
}
|
||||
const properties = assertRecord(schema.properties, filePath, `${field}.properties`);
|
||||
if (Object.keys(properties).length > 64) {
|
||||
fail(filePath, `${field}.properties`, 'object schemas may contain at most 64 properties');
|
||||
}
|
||||
for (const [name, child] of Object.entries(properties)) {
|
||||
if (!V2_PROPERTY_NAME_PATTERN.test(name)) {
|
||||
fail(filePath, `${field}.properties.${name}`, 'property name is invalid');
|
||||
}
|
||||
validateV2SchemaNode(child, filePath, `${field}.properties.${name}`, depth + 1);
|
||||
}
|
||||
const required = schema.required === undefined
|
||||
? []
|
||||
: uniqueStrings(schema.required, filePath, `${field}.required`, V2_PROPERTY_NAME_PATTERN);
|
||||
for (const name of required) {
|
||||
if (!(name in properties)) fail(filePath, `${field}.required`, `required property is not declared: ${name}`);
|
||||
}
|
||||
for (const key of ['items', 'minLength', 'maxLength', 'minimum', 'maximum', 'maxItems']) {
|
||||
if (key in schema) fail(filePath, `${field}.${key}`, `${key} is not valid for an object schema`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (type === 'array') {
|
||||
if (!('items' in schema)) fail(filePath, `${field}.items`, 'array schemas must declare items');
|
||||
boundedInteger(schema.maxItems, filePath, `${field}.maxItems`);
|
||||
validateV2SchemaNode(schema.items, filePath, `${field}.items`, depth + 1);
|
||||
for (const key of ['additionalProperties', 'required', 'properties', 'minLength', 'maxLength', 'minimum', 'maximum']) {
|
||||
if (key in schema) fail(filePath, `${field}.${key}`, `${key} is not valid for an array schema`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ('items' in schema || 'properties' in schema || 'required' in schema
|
||||
|| 'additionalProperties' in schema || 'maxItems' in schema) {
|
||||
fail(filePath, field, 'nested schema keywords do not match the scalar type');
|
||||
}
|
||||
if (type === 'string') {
|
||||
const maxLength = boundedInteger(schema.maxLength, filePath, `${field}.maxLength`);
|
||||
if ('minLength' in schema) {
|
||||
const minLength = boundedInteger(schema.minLength, filePath, `${field}.minLength`);
|
||||
if (minLength > maxLength) fail(filePath, field, 'minLength cannot exceed maxLength');
|
||||
}
|
||||
for (const key of ['minimum', 'maximum']) {
|
||||
if (key in schema) fail(filePath, `${field}.${key}`, `${key} is not valid for a string schema`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (type === 'integer' || type === 'number') {
|
||||
const minimum = finiteNumber(schema.minimum, filePath, `${field}.minimum`);
|
||||
const maximum = finiteNumber(schema.maximum, filePath, `${field}.maximum`);
|
||||
if (minimum > maximum) fail(filePath, field, 'minimum cannot exceed maximum');
|
||||
for (const key of ['minLength', 'maxLength']) {
|
||||
if (key in schema) fail(filePath, `${field}.${key}`, `${key} is not valid for a numeric schema`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateDestructiveConfirmation(
|
||||
schema: UnknownRecord,
|
||||
filePath: string,
|
||||
@@ -331,7 +536,7 @@ export function parseAgentPluginsRootManifest(
|
||||
}
|
||||
const name = stableId(root.name, filePath, 'name', /^[a-z][a-z0-9.-]{0,127}$/u);
|
||||
const version = text(root.version, filePath, 'version', 128);
|
||||
if (!SEMVER_PATTERN.test(version)) fail(filePath, 'version', 'must be a valid package semver');
|
||||
if (!isValidSemVer(version)) fail(filePath, 'version', 'must be a valid package semver');
|
||||
const description = text(root.description, filePath, 'description');
|
||||
const author = assertRecord(root.author, filePath, 'author');
|
||||
assertExactKeys(author, AUTHOR_KEYS, filePath, 'author');
|
||||
@@ -466,14 +671,241 @@ function validateDataServiceDefinition(
|
||||
}
|
||||
}
|
||||
|
||||
function trustedMetadata(
|
||||
options: CodingPluginManifestParseOptions,
|
||||
declaredRuntimeKind: CodingPluginRuntimeKind,
|
||||
packageRoot: string,
|
||||
schemaVersion: 1 | 2,
|
||||
filePath: string,
|
||||
): Pick<CodingPluginDefinition, 'runtimeKind' | 'acquisitionMode' | 'releaseId' | 'provenance'> {
|
||||
const runtimeKind = options.runtimeKind ?? declaredRuntimeKind;
|
||||
if (runtimeKind !== declaredRuntimeKind) {
|
||||
fail(filePath, 'trusted metadata.runtimeKind', 'does not match the package runtime declaration');
|
||||
}
|
||||
const expectedAcquisition: CodingPluginAcquisitionMode = schemaVersion === 1
|
||||
? 'system_included'
|
||||
: 'user_acquired';
|
||||
const acquisitionMode = options.acquisitionMode ?? expectedAcquisition;
|
||||
if (acquisitionMode !== expectedAcquisition) {
|
||||
fail(filePath, 'trusted metadata.acquisitionMode', `must equal ${expectedAcquisition} for schema ${schemaVersion}`);
|
||||
}
|
||||
const releaseId = options.releaseId ?? null;
|
||||
if (releaseId !== null && (typeof releaseId !== 'string' || releaseId.length === 0 || releaseId.length > 128)) {
|
||||
fail(filePath, 'trusted metadata.releaseId', 'must be null or a bounded non-empty string');
|
||||
}
|
||||
if (schemaVersion === 1 && releaseId !== null) {
|
||||
fail(filePath, 'trusted metadata.releaseId', 'bundled schema-1 definitions cannot carry a Release ID');
|
||||
}
|
||||
const defaultProvenance: CodingPluginPackageProvenance = {
|
||||
source: schemaVersion === 1 ? 'bundled' : 'marketplace',
|
||||
packageRoot: schemaVersion === 1 ? path.basename(packageRoot) : path.resolve(packageRoot),
|
||||
};
|
||||
const provenance = options.provenance ?? defaultProvenance;
|
||||
if (provenance.source !== defaultProvenance.source
|
||||
|| typeof provenance.packageRoot !== 'string'
|
||||
|| provenance.packageRoot.length === 0
|
||||
|| provenance.packageRoot.length > 1024) {
|
||||
fail(filePath, 'trusted metadata.provenance', 'is not valid for this package source');
|
||||
}
|
||||
return {
|
||||
runtimeKind,
|
||||
acquisitionMode,
|
||||
releaseId,
|
||||
provenance: {
|
||||
source: provenance.source,
|
||||
packageRoot: provenance.packageRoot,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseV2Skill(
|
||||
value: unknown,
|
||||
index: number,
|
||||
packageRoot: string,
|
||||
capabilityDirectory: string,
|
||||
filePath: string,
|
||||
): CodingPluginSkillDefinition {
|
||||
const skill = assertRecord(value, filePath, `skills[${index}]`);
|
||||
assertExactKeys(skill, V2_SKILL_KEYS, filePath, `skills[${index}]`);
|
||||
const id = stableId(skill.id, filePath, `skills[${index}].id`, SKILL_ID_PATTERN);
|
||||
const entryPath = normalizeV2CandidatePath(
|
||||
packageRoot,
|
||||
capabilityDirectory,
|
||||
skill.entry,
|
||||
filePath,
|
||||
`skills[${index}].entry`,
|
||||
);
|
||||
const grants = uniqueStrings(
|
||||
skill.grants,
|
||||
filePath,
|
||||
`skills[${index}].grants`,
|
||||
CAPABILITY_ID_PATTERN,
|
||||
);
|
||||
return { id, entryPath, grants };
|
||||
}
|
||||
|
||||
function hostedPermissionMatchesPlugin(permission: string, pluginId: string): boolean {
|
||||
if (!HOSTED_PERMISSION_PATTERN.test(permission)) return false;
|
||||
const suffix = pluginId.startsWith('makelore.') ? pluginId.slice('makelore.'.length) : pluginId;
|
||||
return permission.startsWith(`hosted.${suffix}.`);
|
||||
}
|
||||
|
||||
function parseV2Tool(
|
||||
value: unknown,
|
||||
index: number,
|
||||
pluginId: string,
|
||||
filePath: string,
|
||||
): CodingPluginToolDefinition {
|
||||
const tool = assertRecord(value, filePath, `tools[${index}]`);
|
||||
assertExactKeys(tool, V2_TOOL_KEYS, filePath, `tools[${index}]`);
|
||||
const name = stableId(tool.name, filePath, `tools[${index}].name`, TOOL_NAME_PATTERN);
|
||||
const label = text(tool.label, filePath, `tools[${index}].label`);
|
||||
const description = text(tool.description, filePath, `tools[${index}].description`);
|
||||
const capabilityId = stableId(tool.capabilityId, filePath, `tools[${index}].capabilityId`, CAPABILITY_ID_PATTERN);
|
||||
const operation = stableId(tool.operation, filePath, `tools[${index}].operation`, OPERATION_ID_PATTERN);
|
||||
const roles = uniqueStrings(tool.roles, filePath, `tools[${index}].roles`, /^[a-z]+$/u);
|
||||
if (roles.length !== 1 || roles[0] !== 'parent') {
|
||||
fail(filePath, `tools[${index}].roles`, 'distributed tools must be exactly parent-only');
|
||||
}
|
||||
const mutation = tool.mutation;
|
||||
if (mutation !== 'read' && mutation !== 'write' && mutation !== 'destructive') {
|
||||
fail(filePath, `tools[${index}].mutation`, 'unknown tool mutation');
|
||||
}
|
||||
const projectWriteLease = bool(tool.projectWriteLease, filePath, `tools[${index}].projectWriteLease`);
|
||||
if (projectWriteLease) fail(filePath, `tools[${index}].projectWriteLease`, 'distributed tools cannot request a project write lease');
|
||||
const permissions = uniqueStrings(
|
||||
tool.permissions,
|
||||
filePath,
|
||||
`tools[${index}].permissions`,
|
||||
/^[a-z][a-z0-9._-]{0,127}$/u,
|
||||
);
|
||||
if (permissions.some((permission) => !hostedPermissionMatchesPlugin(permission, pluginId))) {
|
||||
fail(filePath, `tools[${index}].permissions`, 'permission is outside the plugin hosted namespace');
|
||||
}
|
||||
if (tool.executionMode !== 'synchronous' && tool.executionMode !== 'accepted') {
|
||||
fail(filePath, `tools[${index}].executionMode`, 'must be synchronous or accepted');
|
||||
}
|
||||
validateV2SchemaNode(tool.inputSchema, filePath, `tools[${index}].inputSchema`);
|
||||
validateV2SchemaNode(tool.outputSchema, filePath, `tools[${index}].outputSchema`);
|
||||
const inputSchema = tool.inputSchema as UnknownRecord;
|
||||
const outputSchema = tool.outputSchema as UnknownRecord;
|
||||
if (inputSchema.type !== 'object' || inputSchema.additionalProperties !== false) {
|
||||
fail(filePath, `tools[${index}].inputSchema`, 'tool input schema must be a closed object');
|
||||
}
|
||||
if (outputSchema.type !== 'object' || outputSchema.additionalProperties !== false) {
|
||||
fail(filePath, `tools[${index}].outputSchema`, 'tool output schema must be a closed object');
|
||||
}
|
||||
if (mutation === 'destructive') validateDestructiveConfirmation(inputSchema, filePath, `tools[${index}].inputSchema`);
|
||||
return {
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
capabilityId,
|
||||
operation,
|
||||
roles: ['parent'],
|
||||
mutation: mutation as PluginToolMutation,
|
||||
projectWriteLease: false,
|
||||
permissions,
|
||||
executionMode: tool.executionMode as CodingPluginExecutionMode,
|
||||
inputSchema: freezeDeep(structuredClone(inputSchema)),
|
||||
outputSchema: freezeDeep(structuredClone(outputSchema)),
|
||||
};
|
||||
}
|
||||
|
||||
function parseV2CodingPluginManifest(
|
||||
root: AgentPluginsRootManifest,
|
||||
capability: UnknownRecord,
|
||||
packageRoot: string,
|
||||
capabilityManifestPath: string,
|
||||
options: CodingPluginManifestParseOptions,
|
||||
): CodingPluginDefinition {
|
||||
assertExactKeys(capability, V2_CAPABILITY_KEYS, capabilityManifestPath, 'root');
|
||||
rejectUnsupportedComponents(capability, capabilityManifestPath, 'root');
|
||||
if (capability.schemaVersion !== 2) fail(capabilityManifestPath, 'schemaVersion', 'must equal 2');
|
||||
const pluginId = stableId(capability.pluginId, capabilityManifestPath, 'pluginId', /^[a-z][a-z0-9.-]{0,127}$/u);
|
||||
if (pluginId !== root.name) fail(capabilityManifestPath, 'pluginId', 'must match plugin.json name');
|
||||
const contractVersion = positiveInteger(capability.contractVersion, capabilityManifestPath, 'contractVersion');
|
||||
if (capability.scope !== 'project') fail(capabilityManifestPath, 'scope', 'distributed plugins must be project-scoped');
|
||||
const runtime = assertRecord(capability.runtime, capabilityManifestPath, 'runtime');
|
||||
assertExactKeys(runtime, V2_RUNTIME_KEYS, capabilityManifestPath, 'runtime');
|
||||
const runtimeKind = runtime.kind;
|
||||
if (runtimeKind !== 'skill_only' && runtimeKind !== 'platform_hosted') {
|
||||
fail(capabilityManifestPath, 'runtime.kind', 'must be skill_only or platform_hosted');
|
||||
}
|
||||
if (runtimeKind === 'skill_only') {
|
||||
if ('protocol' in runtime) fail(capabilityManifestPath, 'runtime.protocol', 'skill_only must not declare a hosted protocol');
|
||||
} else if (runtime.protocol !== 'makelore-hosted.v1') {
|
||||
fail(capabilityManifestPath, 'runtime.protocol', 'must equal makelore-hosted.v1');
|
||||
}
|
||||
const skillsValue = capability.skills;
|
||||
if (!Array.isArray(skillsValue) || skillsValue.length === 0) {
|
||||
fail(capabilityManifestPath, 'skills', 'must contain at least one Skill');
|
||||
}
|
||||
const capabilityDirectory = path.dirname(capabilityManifestPath);
|
||||
const skills = skillsValue.map((value, index) => parseV2Skill(
|
||||
value,
|
||||
index,
|
||||
packageRoot,
|
||||
capabilityDirectory,
|
||||
capabilityManifestPath,
|
||||
));
|
||||
if (new Set(skills.map((skill) => skill.id)).size !== skills.length) {
|
||||
fail(capabilityManifestPath, 'skills', 'duplicate Skill identifier');
|
||||
}
|
||||
if (new Set(skills.map((skill) => skill.entryPath)).size !== skills.length) {
|
||||
fail(capabilityManifestPath, 'skills', 'duplicate Skill entry path');
|
||||
}
|
||||
if (!Array.isArray(capability.tools)) fail(capabilityManifestPath, 'tools', 'must be an array');
|
||||
if (runtimeKind === 'skill_only' && capability.tools.length !== 0) {
|
||||
fail(capabilityManifestPath, 'tools', 'skill_only packages must declare an empty tools array');
|
||||
}
|
||||
if (runtimeKind === 'platform_hosted' && capability.tools.length === 0) {
|
||||
fail(capabilityManifestPath, 'tools', 'platform_hosted packages must declare at least one tool');
|
||||
}
|
||||
const tools = capability.tools.map((value, index) => parseV2Tool(
|
||||
value,
|
||||
index,
|
||||
pluginId,
|
||||
capabilityManifestPath,
|
||||
));
|
||||
if (new Set(tools.map((tool) => tool.name)).size !== tools.length) {
|
||||
fail(capabilityManifestPath, 'tools.name', 'duplicate tool identifier');
|
||||
}
|
||||
const mappingKeys = tools.map((tool) => `${tool.capabilityId}\u0000${tool.operation}`);
|
||||
if (new Set(mappingKeys).size !== mappingKeys.length) {
|
||||
fail(capabilityManifestPath, 'tools', 'duplicate capability operation mapping');
|
||||
}
|
||||
const grants = new Set(skills.flatMap((skill) => skill.grants));
|
||||
for (const tool of tools) {
|
||||
if (!grants.has(tool.capabilityId)) {
|
||||
fail(capabilityManifestPath, `tools.${tool.name}.capabilityId`, 'tool capability has no Skill grant');
|
||||
}
|
||||
}
|
||||
if (runtimeKind === 'skill_only' && skills.some((skill) => skill.grants.length > 0)) {
|
||||
fail(capabilityManifestPath, 'skills', 'skill_only Skills must declare empty grants');
|
||||
}
|
||||
const metadata = trustedMetadata(options, runtimeKind, packageRoot, 2, capabilityManifestPath);
|
||||
return freezeDeep({
|
||||
id: pluginId,
|
||||
version: root.version,
|
||||
contractVersion,
|
||||
displayName: root.name,
|
||||
description: root.description,
|
||||
...metadata,
|
||||
scope: 'project' as const,
|
||||
adapterId: '',
|
||||
requiresBackend: runtimeKind === 'platform_hosted',
|
||||
skills,
|
||||
tools,
|
||||
operations: tools.map(({ capabilityId, operation, name }) => ({ capabilityId, operation, toolName: name })),
|
||||
surfaces: {},
|
||||
});
|
||||
}
|
||||
|
||||
export function parseCodingPluginManifest(
|
||||
rootValue: unknown,
|
||||
capabilityValue: unknown,
|
||||
options: {
|
||||
packageRoot?: string;
|
||||
rootManifestPath?: string;
|
||||
capabilityManifestPath?: string;
|
||||
} = {},
|
||||
options: CodingPluginManifestParseOptions = {},
|
||||
): CodingPluginDefinition {
|
||||
const packageRoot = path.resolve(options.packageRoot ?? path.dirname(options.capabilityManifestPath ?? '.'));
|
||||
const rootManifestPath = path.resolve(
|
||||
@@ -488,10 +920,32 @@ export function parseCodingPluginManifest(
|
||||
|| capabilityRelativePath.startsWith(`..${path.sep}`) || path.isAbsolute(capabilityRelativePath)) {
|
||||
fail(capabilityManifestPath, 'root', 'capability manifest must be inside the package root');
|
||||
}
|
||||
const declaredCapabilityPath = normalizeCapabilityManifestPath(
|
||||
packageRoot,
|
||||
root.extensions['com.makelore'].capabilityManifest,
|
||||
rootManifestPath,
|
||||
'extensions.com.makelore.capabilityManifest',
|
||||
);
|
||||
if (declaredCapabilityPath !== CAPABILITY_MANIFEST_RELATIVE_PATH) {
|
||||
fail(
|
||||
rootManifestPath,
|
||||
'extensions.com.makelore.capabilityManifest',
|
||||
`must point to ${CAPABILITY_MANIFEST_RELATIVE_PATH}`,
|
||||
);
|
||||
}
|
||||
const capability = assertRecord(capabilityValue, capabilityManifestPath, 'root');
|
||||
if (capability.schemaVersion === 2) {
|
||||
return parseV2CodingPluginManifest(
|
||||
root,
|
||||
capability,
|
||||
packageRoot,
|
||||
capabilityManifestPath,
|
||||
options,
|
||||
);
|
||||
}
|
||||
if (capability.schemaVersion !== 1) fail(capabilityManifestPath, 'schemaVersion', 'must equal 1 or 2');
|
||||
assertExactKeys(capability, CAPABILITY_KEYS, capabilityManifestPath, 'root');
|
||||
rejectUnsupportedComponents(capability, capabilityManifestPath, 'root');
|
||||
if (capability.schemaVersion !== 1) fail(capabilityManifestPath, 'schemaVersion', 'must equal 1');
|
||||
const pluginId = stableId(capability.pluginId, capabilityManifestPath, 'pluginId', /^[a-z][a-z0-9.-]{0,127}$/u);
|
||||
if (pluginId !== root.name) fail(capabilityManifestPath, 'pluginId', 'must match plugin.json name');
|
||||
const contractVersion = positiveInteger(capability.contractVersion, capabilityManifestPath, 'contractVersion');
|
||||
@@ -560,6 +1014,7 @@ export function parseCodingPluginManifest(
|
||||
contractVersion,
|
||||
displayName,
|
||||
description,
|
||||
...trustedMetadata(options, 'bundled_typed', packageRoot, 1, capabilityManifestPath),
|
||||
scope: 'project' as const,
|
||||
adapterId,
|
||||
requiresBackend,
|
||||
@@ -582,13 +1037,15 @@ function readJson(source: string, filePath: string): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadCodingPluginDefinition(packageRoot: string): Promise<CodingPluginDefinition> {
|
||||
export async function loadCodingPluginDefinition(
|
||||
packageRoot: string,
|
||||
options: CodingPluginLoadOptions = {},
|
||||
): Promise<CodingPluginDefinition> {
|
||||
const root = path.resolve(packageRoot);
|
||||
const pluginManifestPath = path.join(root, PACKAGE_MANIFEST_FILE);
|
||||
const rootValue = readJson(await readFile(pluginManifestPath, 'utf8'), pluginManifestPath);
|
||||
const rootManifest = parseAgentPluginsRootManifest(rootValue, pluginManifestPath);
|
||||
const capabilityManifest = normalizeCandidatePath(
|
||||
root,
|
||||
const capabilityManifest = normalizeCapabilityManifestPath(
|
||||
root,
|
||||
rootManifest.extensions['com.makelore'].capabilityManifest,
|
||||
pluginManifestPath,
|
||||
@@ -603,6 +1060,7 @@ export async function loadCodingPluginDefinition(packageRoot: string): Promise<C
|
||||
packageRoot: root,
|
||||
rootManifestPath: pluginManifestPath,
|
||||
capabilityManifestPath,
|
||||
...options,
|
||||
});
|
||||
for (const skill of definition.skills) {
|
||||
try {
|
||||
@@ -618,13 +1076,15 @@ export async function loadCodingPluginDefinition(packageRoot: string): Promise<C
|
||||
return definition;
|
||||
}
|
||||
|
||||
export function loadCodingPluginDefinitionSync(packageRoot: string): CodingPluginDefinition {
|
||||
export function loadCodingPluginDefinitionSync(
|
||||
packageRoot: string,
|
||||
options: CodingPluginLoadOptions = {},
|
||||
): CodingPluginDefinition {
|
||||
const root = path.resolve(packageRoot);
|
||||
const pluginManifestPath = path.join(root, PACKAGE_MANIFEST_FILE);
|
||||
const rootValue = readJson(readFileSync(pluginManifestPath, 'utf8'), pluginManifestPath);
|
||||
const rootManifest = parseAgentPluginsRootManifest(rootValue, pluginManifestPath);
|
||||
const capabilityManifest = normalizeCandidatePath(
|
||||
root,
|
||||
const capabilityManifest = normalizeCapabilityManifestPath(
|
||||
root,
|
||||
rootManifest.extensions['com.makelore'].capabilityManifest,
|
||||
pluginManifestPath,
|
||||
@@ -639,6 +1099,7 @@ export function loadCodingPluginDefinitionSync(packageRoot: string): CodingPlugi
|
||||
packageRoot: root,
|
||||
rootManifestPath: pluginManifestPath,
|
||||
capabilityManifestPath,
|
||||
...options,
|
||||
});
|
||||
for (const skill of definition.skills) {
|
||||
try {
|
||||
@@ -690,8 +1151,13 @@ export async function loadBundledCodingPluginDefinitions(
|
||||
): Promise<readonly CodingPluginDefinition[]> {
|
||||
const roots = resolveBundledCodingPluginRootPaths(resourcesRoot);
|
||||
const definitions = await Promise.all(roots.map(async (root, index) => {
|
||||
const definition = await loadCodingPluginDefinition(root);
|
||||
const expectedRoot = BUNDLED_CODING_PLUGIN_ROOTS[index];
|
||||
const definition = await loadCodingPluginDefinition(root, {
|
||||
runtimeKind: 'bundled_typed',
|
||||
acquisitionMode: 'system_included',
|
||||
releaseId: null,
|
||||
provenance: { source: 'bundled', packageRoot: expectedRoot },
|
||||
});
|
||||
if (definition.id !== `makelore.${expectedRoot}`) {
|
||||
throw new CodingPluginManifestError(
|
||||
path.join(root, PACKAGE_MANIFEST_FILE),
|
||||
@@ -710,8 +1176,13 @@ export function loadBundledCodingPluginDefinitionsSync(
|
||||
): readonly CodingPluginDefinition[] {
|
||||
const roots = resolveBundledCodingPluginRootPaths(resourcesRoot);
|
||||
const definitions = roots.map((root, index) => {
|
||||
const definition = loadCodingPluginDefinitionSync(root);
|
||||
const expectedRoot = BUNDLED_CODING_PLUGIN_ROOTS[index];
|
||||
const definition = loadCodingPluginDefinitionSync(root, {
|
||||
runtimeKind: 'bundled_typed',
|
||||
acquisitionMode: 'system_included',
|
||||
releaseId: null,
|
||||
provenance: { source: 'bundled', packageRoot: expectedRoot },
|
||||
});
|
||||
if (definition.id !== `makelore.${expectedRoot}`) {
|
||||
throw new CodingPluginManifestError(
|
||||
path.join(root, PACKAGE_MANIFEST_FILE),
|
||||
|
||||
252
electron/coding-plugins/release-descriptor.ts
Normal file
252
electron/coding-plugins/release-descriptor.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
|
||||
export const PLUGIN_RELEASE_DESCRIPTOR_SCHEMA = 'makelore-plugin-release.v1' as const;
|
||||
|
||||
const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9.-]{0,127}$/u;
|
||||
const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u;
|
||||
const DIGEST_PATTERN = /^[a-f0-9]{64}$/u;
|
||||
const DESCRIPTOR_INPUT_KEYS = [
|
||||
'pluginId',
|
||||
'version',
|
||||
'packageSchemaVersion',
|
||||
'contractVersion',
|
||||
'minMakeloreVersion',
|
||||
'maxMakeloreVersion',
|
||||
'artifact',
|
||||
] as const;
|
||||
|
||||
export type PluginReleaseDescriptorInput = {
|
||||
readonly pluginId: string;
|
||||
readonly version: string;
|
||||
readonly packageSchemaVersion: number;
|
||||
readonly contractVersion: number;
|
||||
readonly minMakeloreVersion: string;
|
||||
readonly maxMakeloreVersion: string | null;
|
||||
readonly artifact: {
|
||||
readonly sha256: string;
|
||||
readonly sizeBytes: number;
|
||||
};
|
||||
};
|
||||
|
||||
export interface PluginReleaseDescriptor extends PluginReleaseDescriptorInput {
|
||||
readonly schema: typeof PLUGIN_RELEASE_DESCRIPTOR_SCHEMA;
|
||||
}
|
||||
|
||||
export class PluginReleaseDescriptorError extends Error {
|
||||
readonly code = 'plugin_descriptor_invalid' as const;
|
||||
|
||||
constructor(readonly field: string, message: string) {
|
||||
super(`Invalid plugin release descriptor (${field}): ${message}`);
|
||||
this.name = 'PluginReleaseDescriptorError';
|
||||
}
|
||||
}
|
||||
|
||||
interface ParsedSemVer {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
prerelease: readonly (number | string)[];
|
||||
}
|
||||
|
||||
function fail(field: string, message: string): never {
|
||||
throw new PluginReleaseDescriptorError(field, message);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function assertDescriptorInputKeys(value: Record<string, unknown>, includeSchema: boolean): void {
|
||||
const allowed = new Set<string>([
|
||||
...DESCRIPTOR_INPUT_KEYS,
|
||||
...(includeSchema ? ['schema'] : []),
|
||||
]);
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowed.has(key)) fail(`root.${key}`, 'unknown field');
|
||||
}
|
||||
}
|
||||
|
||||
function parseSemVer(value: unknown, field: string): ParsedSemVer {
|
||||
if (typeof value !== 'string' || value.length > 128) fail(field, 'must be a SemVer string');
|
||||
const match = SEMVER_PATTERN.exec(value);
|
||||
if (!match) fail(field, 'must be a valid SemVer');
|
||||
const prerelease = (match[4] ?? '').split('.').filter(Boolean).map((item) => {
|
||||
if (/^\d+$/u.test(item)) {
|
||||
if (item.length > 1 && item.startsWith('0')) fail(field, 'numeric prerelease identifiers cannot have leading zeroes');
|
||||
return Number(item);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
return {
|
||||
major: Number(match[1]),
|
||||
minor: Number(match[2]),
|
||||
patch: Number(match[3]),
|
||||
prerelease,
|
||||
};
|
||||
}
|
||||
|
||||
export function isValidSemVer(value: unknown): value is string {
|
||||
try {
|
||||
parseSemVer(value, 'version');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function compareSemVer(left: string, right: string): -1 | 0 | 1 {
|
||||
const a = parseSemVer(left, 'left');
|
||||
const b = parseSemVer(right, 'right');
|
||||
for (const key of ['major', 'minor', 'patch'] as const) {
|
||||
if (a[key] < b[key]) return -1;
|
||||
if (a[key] > b[key]) return 1;
|
||||
}
|
||||
if (a.prerelease.length === 0 && b.prerelease.length === 0) return 0;
|
||||
if (a.prerelease.length === 0) return 1;
|
||||
if (b.prerelease.length === 0) return -1;
|
||||
const length = Math.max(a.prerelease.length, b.prerelease.length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const leftPart = a.prerelease[index];
|
||||
const rightPart = b.prerelease[index];
|
||||
if (leftPart === undefined) return -1;
|
||||
if (rightPart === undefined) return 1;
|
||||
if (leftPart === rightPart) continue;
|
||||
if (typeof leftPart === 'number' && typeof rightPart === 'string') return -1;
|
||||
if (typeof leftPart === 'string' && typeof rightPart === 'number') return 1;
|
||||
return leftPart < rightPart ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function isMakeLoreVersionCompatible(
|
||||
clientVersion: string,
|
||||
minimumVersion: string,
|
||||
maximumVersion: string | null,
|
||||
): boolean {
|
||||
if (!isValidSemVer(clientVersion) || !isValidSemVer(minimumVersion)
|
||||
|| (maximumVersion !== null && !isValidSemVer(maximumVersion))) return false;
|
||||
if (compareSemVer(clientVersion, minimumVersion) < 0) return false;
|
||||
return maximumVersion === null || compareSemVer(clientVersion, maximumVersion) <= 0;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, field: string, allowZero = false): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < (allowZero ? 0 : 1)) {
|
||||
fail(field, allowZero ? 'must be a non-negative safe integer' : 'must be a positive safe integer');
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function descriptorFromInput(input: PluginReleaseDescriptorInput): PluginReleaseDescriptor {
|
||||
if (!isRecord(input)) fail('root', 'must be an object');
|
||||
assertDescriptorInputKeys(input, 'schema' in input);
|
||||
if ('schema' in input && input.schema !== PLUGIN_RELEASE_DESCRIPTOR_SCHEMA) {
|
||||
fail('schema', `must equal ${PLUGIN_RELEASE_DESCRIPTOR_SCHEMA}`);
|
||||
}
|
||||
if (typeof input.pluginId !== 'string' || !PLUGIN_ID_PATTERN.test(input.pluginId)) {
|
||||
fail('plugin_id', 'must be a stable Plugin ID');
|
||||
}
|
||||
parseSemVer(input.version, 'version');
|
||||
if (input.packageSchemaVersion !== 2) fail('package_schema_version', 'must equal 2 for an artifact Release');
|
||||
positiveInteger(input.contractVersion, 'contract_version');
|
||||
parseSemVer(input.minMakeloreVersion, 'min_makelore_version');
|
||||
if (input.maxMakeloreVersion !== null) {
|
||||
parseSemVer(input.maxMakeloreVersion, 'max_makelore_version');
|
||||
if (compareSemVer(input.maxMakeloreVersion, input.minMakeloreVersion) < 0) {
|
||||
fail('max_makelore_version', 'must not be lower than min_makelore_version');
|
||||
}
|
||||
}
|
||||
if (!isRecord(input.artifact)) fail('artifact', 'must be an object');
|
||||
if (typeof input.artifact.sha256 !== 'string' || !DIGEST_PATTERN.test(input.artifact.sha256)) {
|
||||
fail('artifact.sha256', 'must be 64 lowercase hexadecimal characters');
|
||||
}
|
||||
positiveInteger(input.artifact.sizeBytes, 'artifact.size_bytes', true);
|
||||
return Object.freeze({
|
||||
schema: PLUGIN_RELEASE_DESCRIPTOR_SCHEMA,
|
||||
pluginId: input.pluginId,
|
||||
version: input.version,
|
||||
packageSchemaVersion: input.packageSchemaVersion,
|
||||
contractVersion: input.contractVersion,
|
||||
minMakeloreVersion: input.minMakeloreVersion,
|
||||
maxMakeloreVersion: input.maxMakeloreVersion,
|
||||
artifact: Object.freeze({
|
||||
sha256: input.artifact.sha256,
|
||||
sizeBytes: input.artifact.sizeBytes,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function buildPluginReleaseDescriptor(input: PluginReleaseDescriptorInput): PluginReleaseDescriptor {
|
||||
return descriptorFromInput(input);
|
||||
}
|
||||
|
||||
export const createPluginReleaseDescriptor = buildPluginReleaseDescriptor;
|
||||
|
||||
export function serializePluginReleaseDescriptor(descriptor: PluginReleaseDescriptor): Buffer {
|
||||
if (!isRecord(descriptor) || descriptor.schema !== PLUGIN_RELEASE_DESCRIPTOR_SCHEMA) {
|
||||
fail('schema', `must equal ${PLUGIN_RELEASE_DESCRIPTOR_SCHEMA}`);
|
||||
}
|
||||
const valid = descriptorFromInput(descriptor);
|
||||
return Buffer.from(JSON.stringify({
|
||||
schema: valid.schema,
|
||||
plugin_id: valid.pluginId,
|
||||
version: valid.version,
|
||||
package_schema_version: valid.packageSchemaVersion,
|
||||
contract_version: valid.contractVersion,
|
||||
min_makelore_version: valid.minMakeloreVersion,
|
||||
max_makelore_version: valid.maxMakeloreVersion,
|
||||
artifact: {
|
||||
sha256: valid.artifact.sha256,
|
||||
size_bytes: valid.artifact.sizeBytes,
|
||||
},
|
||||
}), 'utf8');
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, keys: readonly string[], field: string): void {
|
||||
const allowed = new Set(keys);
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowed.has(key)) fail(`${field}.${key}`, 'unknown field');
|
||||
}
|
||||
for (const key of keys) {
|
||||
if (!(key in value)) fail(`${field}.${key}`, 'required field is missing');
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePluginReleaseDescriptor(input: Uint8Array | string): PluginReleaseDescriptor {
|
||||
const bytes = typeof input === 'string' ? Buffer.from(input, 'utf8') : Buffer.from(input);
|
||||
if (bytes.length === 0 || bytes.length > 16_384) fail('root', 'descriptor size is outside the supported bound');
|
||||
const source = bytes.toString('utf8');
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(source) as unknown;
|
||||
} catch {
|
||||
fail('root', 'must be valid UTF-8 JSON');
|
||||
}
|
||||
const root = isRecord(value) ? value : fail('root', 'must be an object');
|
||||
exactKeys(root, [
|
||||
'schema', 'plugin_id', 'version', 'package_schema_version', 'contract_version',
|
||||
'min_makelore_version', 'max_makelore_version', 'artifact',
|
||||
], 'root');
|
||||
if (root.schema !== PLUGIN_RELEASE_DESCRIPTOR_SCHEMA) {
|
||||
fail('schema', `must equal ${PLUGIN_RELEASE_DESCRIPTOR_SCHEMA}`);
|
||||
}
|
||||
const artifact = isRecord(root.artifact) ? root.artifact : fail('artifact', 'must be an object');
|
||||
exactKeys(artifact, ['sha256', 'size_bytes'], 'artifact');
|
||||
const descriptor = descriptorFromInput({
|
||||
pluginId: root.plugin_id as string,
|
||||
version: root.version as string,
|
||||
packageSchemaVersion: root.package_schema_version as number,
|
||||
contractVersion: root.contract_version as number,
|
||||
minMakeloreVersion: root.min_makelore_version as string,
|
||||
maxMakeloreVersion: root.max_makelore_version as string | null,
|
||||
artifact: {
|
||||
sha256: artifact.sha256 as string,
|
||||
sizeBytes: artifact.size_bytes as number,
|
||||
},
|
||||
});
|
||||
const canonical = serializePluginReleaseDescriptor(descriptor);
|
||||
if (!Buffer.from(canonical).equals(bytes)) fail('root', 'descriptor must use the canonical compact byte representation');
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
export const parseReleaseDescriptor = parsePluginReleaseDescriptor;
|
||||
export const serializeReleaseDescriptor = serializePluginReleaseDescriptor;
|
||||
155
electron/coding-plugins/signature-verifier.ts
Normal file
155
electron/coding-plugins/signature-verifier.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { createHash, createPublicKey, verify } from 'node:crypto';
|
||||
import type { KeyObject } from 'node:crypto';
|
||||
import {
|
||||
isMakeLoreVersionCompatible,
|
||||
parsePluginReleaseDescriptor,
|
||||
serializePluginReleaseDescriptor,
|
||||
type PluginReleaseDescriptor,
|
||||
} from './release-descriptor';
|
||||
import {
|
||||
createCodeOwnedPluginTrustStore,
|
||||
type PluginSigningKeyStore,
|
||||
} from './trusted-keys';
|
||||
|
||||
export type PluginSignatureFailureCode =
|
||||
| 'plugin_signature_invalid'
|
||||
| 'plugin_artifact_invalid'
|
||||
| 'plugin_incompatible_client';
|
||||
|
||||
export type PluginSignatureVerificationResult =
|
||||
| { readonly ok: true }
|
||||
| { readonly ok: false; readonly code: PluginSignatureFailureCode; readonly message: string };
|
||||
|
||||
export interface PluginSignatureVerifierOptions {
|
||||
readonly keyStore?: PluginSigningKeyStore | ReadonlyMap<string, Uint8Array | string>;
|
||||
readonly clientVersion: string;
|
||||
}
|
||||
|
||||
export interface PluginSignatureVerifier {
|
||||
verify(input: {
|
||||
readonly keyId: string;
|
||||
readonly signature: string | Uint8Array;
|
||||
readonly descriptor: PluginReleaseDescriptor;
|
||||
readonly artifact: Uint8Array;
|
||||
}): PluginSignatureVerificationResult;
|
||||
}
|
||||
|
||||
function failure(code: PluginSignatureFailureCode, message: string): PluginSignatureVerificationResult {
|
||||
return { ok: false, code, message };
|
||||
}
|
||||
|
||||
function keyFromStore(
|
||||
keyStore: PluginSigningKeyStore | ReadonlyMap<string, Uint8Array | string>,
|
||||
keyId: string,
|
||||
): Uint8Array | string | null {
|
||||
const key = keyStore.get(keyId);
|
||||
return key === undefined ? null : key;
|
||||
}
|
||||
|
||||
function publicKey(value: Uint8Array | string): KeyObject | null {
|
||||
try {
|
||||
if (typeof value === 'string' && !value.includes('PUBLIC KEY')) return null;
|
||||
const key = typeof value === 'string'
|
||||
? createPublicKey(value)
|
||||
: createPublicKey({ key: Buffer.from(value), format: 'der', type: 'spki' });
|
||||
return key.asymmetricKeyType === 'ed25519' ? key : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function signatureBytes(value: string | Uint8Array): Buffer | null {
|
||||
try {
|
||||
if (typeof value === 'string'
|
||||
&& (value.length !== 86 || !/^[A-Za-z0-9_-]+$/u.test(value))) return null;
|
||||
const bytes = typeof value === 'string' ? Buffer.from(value, 'base64url') : Buffer.from(value);
|
||||
return bytes.length === 64 ? bytes : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function artifactDigest(artifact: Uint8Array): string {
|
||||
return createHash('sha256').update(artifact).digest('hex');
|
||||
}
|
||||
|
||||
export function createPluginSignatureVerifier(
|
||||
options: PluginSignatureVerifierOptions,
|
||||
): PluginSignatureVerifier {
|
||||
const keyStore = options.keyStore ?? createCodeOwnedPluginTrustStore();
|
||||
return {
|
||||
verify(input) {
|
||||
if (!(input.artifact instanceof Uint8Array)) {
|
||||
return failure('plugin_artifact_invalid', 'artifact bytes are invalid');
|
||||
}
|
||||
let descriptorBytes: Uint8Array;
|
||||
try {
|
||||
descriptorBytes = serializePluginReleaseDescriptor(input.descriptor);
|
||||
} catch {
|
||||
return failure('plugin_signature_invalid', 'descriptor is invalid');
|
||||
}
|
||||
if (input.artifact.byteLength !== input.descriptor.artifact.sizeBytes
|
||||
|| artifactDigest(input.artifact) !== input.descriptor.artifact.sha256) {
|
||||
return failure('plugin_artifact_invalid', 'artifact bytes do not match the descriptor');
|
||||
}
|
||||
if (!isCompatibleClient(options.clientVersion, input.descriptor)) {
|
||||
return failure('plugin_incompatible_client', 'client version is outside the Release range');
|
||||
}
|
||||
const encodedKey = keyFromStore(keyStore, input.keyId);
|
||||
if (encodedKey === null) return failure('plugin_signature_invalid', 'signing key is not trusted');
|
||||
const key = publicKey(encodedKey);
|
||||
const signature = signatureBytes(input.signature);
|
||||
if (!key || !signature || !verify(null, descriptorBytes, key, signature)) {
|
||||
return failure('plugin_signature_invalid', 'descriptor signature is invalid');
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isCompatibleClient(clientVersion: string, descriptor: PluginReleaseDescriptor): boolean {
|
||||
// Kept local to avoid making the verifier infer a range from untrusted fields.
|
||||
return isMakeLoreVersionCompatible(
|
||||
clientVersion,
|
||||
descriptor.minMakeloreVersion,
|
||||
descriptor.maxMakeloreVersion,
|
||||
);
|
||||
}
|
||||
|
||||
export interface VerifyPluginReleaseSignatureInput {
|
||||
readonly verifier?: PluginSignatureVerifier;
|
||||
readonly keyId: string;
|
||||
readonly signature: string | Uint8Array;
|
||||
readonly descriptor: PluginReleaseDescriptor | Uint8Array | string;
|
||||
readonly artifact: Uint8Array;
|
||||
readonly clientVersion?: string;
|
||||
readonly keyStore?: PluginSigningKeyStore | ReadonlyMap<string, Uint8Array | string>;
|
||||
}
|
||||
|
||||
export function verifyPluginReleaseSignature(
|
||||
input: VerifyPluginReleaseSignatureInput,
|
||||
): PluginSignatureVerificationResult {
|
||||
const descriptor = input.descriptor instanceof Uint8Array || typeof input.descriptor === 'string'
|
||||
? (() => {
|
||||
try {
|
||||
return parsePluginReleaseDescriptor(input.descriptor as Uint8Array | string);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
: input.descriptor;
|
||||
if (!descriptor) return failure('plugin_signature_invalid', 'descriptor is invalid');
|
||||
const verifier = input.verifier ?? createPluginSignatureVerifier({
|
||||
keyStore: input.keyStore,
|
||||
clientVersion: input.clientVersion ?? '',
|
||||
});
|
||||
return verifier.verify({
|
||||
keyId: input.keyId,
|
||||
signature: input.signature,
|
||||
descriptor,
|
||||
artifact: input.artifact,
|
||||
});
|
||||
}
|
||||
|
||||
export const verifyReleaseSignature = verifyPluginReleaseSignature;
|
||||
42
electron/coding-plugins/trusted-keys.ts
Normal file
42
electron/coding-plugins/trusted-keys.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
|
||||
/**
|
||||
* The production signing-key resource is deliberately code-owned. The empty
|
||||
* table records the current activation hold until the platform supplies an
|
||||
* official key; there is no environment or runtime override.
|
||||
*/
|
||||
export const CODE_OWNED_PLUGIN_SIGNING_KEYS = Object.freeze(
|
||||
{} as Readonly<Record<string, string>>,
|
||||
);
|
||||
|
||||
export const PLUGIN_SIGNING_KEY_ACTIVATION_HOLD = true as const;
|
||||
|
||||
export interface PluginSigningKeyStore {
|
||||
get(keyId: string): Uint8Array | string | null;
|
||||
}
|
||||
|
||||
function decodeKey(value: string): Uint8Array | null {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
if (trimmed.includes('BEGIN PUBLIC KEY')) return Buffer.from(trimmed, 'utf8');
|
||||
try {
|
||||
const bytes = Buffer.from(trimmed, 'base64');
|
||||
return bytes.length > 0 ? bytes : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadCodeOwnedPluginSigningKey(keyId: string): Uint8Array | null {
|
||||
if (typeof keyId !== 'string' || keyId.length === 0) return null;
|
||||
const encoded = CODE_OWNED_PLUGIN_SIGNING_KEYS[keyId];
|
||||
return encoded === undefined ? null : decodeKey(encoded);
|
||||
}
|
||||
|
||||
export function createCodeOwnedPluginTrustStore(): PluginSigningKeyStore {
|
||||
return Object.freeze({
|
||||
get: loadCodeOwnedPluginSigningKey,
|
||||
});
|
||||
}
|
||||
|
||||
export const getCodeOwnedPluginSigningKey = loadCodeOwnedPluginSigningKey;
|
||||
Reference in New Issue
Block a user