From 898e2b7bdde4bdd77a5659d7b4e294d0607656e3 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Fri, 28 Aug 2026 12:57:50 +0800 Subject: [PATCH] feat(coding): add marketplace package trust primitives --- .../tasks/20260828-plugin-mlm01-5c8d1e4f.md | 148 ++++++ electron/coding-plugins/manifest.ts | 503 +++++++++++++++++- electron/coding-plugins/release-descriptor.ts | 252 +++++++++ electron/coding-plugins/signature-verifier.ts | 155 ++++++ electron/coding-plugins/trusted-keys.ts | 42 ++ shared/coding-plugins.ts | 30 ++ ...coding-plugin-marketplace-contract.test.ts | 272 ++++++++++ 7 files changed, 1386 insertions(+), 16 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260828-plugin-mlm01-5c8d1e4f.md create mode 100644 electron/coding-plugins/release-descriptor.ts create mode 100644 electron/coding-plugins/signature-verifier.ts create mode 100644 electron/coding-plugins/trusted-keys.ts create mode 100644 tests/unit/coding-plugin-marketplace-contract.test.ts diff --git a/.project-docs/30-worklog/tasks/20260828-plugin-mlm01-5c8d1e4f.md b/.project-docs/30-worklog/tasks/20260828-plugin-mlm01-5c8d1e4f.md new file mode 100644 index 0000000..4d0653a --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260828-plugin-mlm01-5c8d1e4f.md @@ -0,0 +1,148 @@ +# Task: Marketplace MLM-01 package selection and signature trust + +## Identity + +- Task ID: 20260828-plugin-mlm01-5c8d1e4f +- Mode: Feature +- Branch: codex/20260828-plugin-mlm01-5c8d1e4f-mlm01-package-trust +- Worktree: D:\Datas\OthersProjects\makelore-plugin-marketplace-client-mlm01-5c8d1e4f +- Base commit: 4d8b1fcec0a751d2935effc7816c7e59f568ec65 +- Owner: codex-mlm01 +- Status: Ready for Integration + +## Scope + +- Implement ticket MLM-01 from the exact Marketplace Release A client frontier: + schema-v2 declarative package parsing, bounded hosted/skill-only definitions, + fixed release-descriptor serialization, Ed25519 verification, strict SemVer + client-range checks, and the code-owned production trust-store seam. +- Own only `shared/coding-plugins.ts`, + `electron/coding-plugins/manifest.ts`, the new descriptor/signature/trusted-key + modules, focused fixtures/tests for those modules, and this task record. +- Do not touch marketplace/network clients, Package Store, account/project + services, Pi/Host/Renderer consumers, server/Operations code, hosted runtime, + billing, or Release B tickets. + +## Project Context Loaded + +- Concurrent Task Gate passed after `check_project_docs.py`; `task_context.py + start` and `status --json` match this task ID, owner, isolated worktree, + branch, mode, and exact base. +- Planning Gate passed after reading `AGENTS.md`, the mandatory project-memory + startup set, relevant architecture/domain/evidence/reflection/commitment/stale + records, the Marketplace implementation spec sections 1-6, 10.1, 13.2-13.3, + the full ticket graph MLM-01, the accepted Marketplace design sections 6, + 11.2-11.5, 14, and 18.3, and peer task records for the coordinator and prior + P0 package/policy/runtime work. +- Current integrated memory is a historical snapshot and predates Marketplace; + the exact implementation spec, ticket graph, design authority, and coordinator + frontier are authoritative. The prior P0 parser is the compatibility baseline, + not a source branch to merge or modify. +- Other active local owners are the Marketplace client coordinator and historical + P0/review tasks in separate worktrees. Their records identify no semantic + conflict with this isolated parser/trust scope. The coordinator owns only + integration/documentation and later Marketplace tickets; MLM-02 owns the + network/Package Store files after this handoff. + +## Intent And Constraints + +- Preserve schema-1 bundled compatibility exactly, including code-owned adapter, + settings, and preview privilege allowlists; schema-2 packages cannot name any + such privilege and cannot narrow arbitrary strings into code-owned IDs. +- Accept only the closed schema-2 vocabulary: `skill_only` with empty tools and + grants, or declarative `platform_hosted` with protocol + `makelore-hosted.v1`, namespaced permissions, closed bounded input/output + schemas, and `projectWriteLease=false`. Parsing does not execute package files + or implement hosted runtime. +- Reject unknown object fields/components/paths, malformed or unbounded JSON + Schema, duplicate tool/capability mappings, unsupported local execution, + invalid identifiers, and schema-v2 adapter/settings/preview claims with stable + validation failures. +- Build the exact compact, fixed-order release descriptor bytes from the closed + shape and verify descriptor identity, artifact size/SHA-256, Ed25519 signature, + known key ID, and MakeLore compatibility range. No generic canonicalizer is + introduced. +- Production trust is code-owned and fail-closed when the official public key is + absent. No environment/runtime public-key override and no production private + key are allowed. Ephemeral keys may be injected only through test/service + dependencies. +- Keep parsed definitions immutable and distinguish trusted Package Store + provenance from manifest self-assertion. Do not add network, install, index, + account, project, registry, Pi, Host, Renderer, billing, or Release B behavior. + +## Implementation Plan + +1. Inspect the exact base parser, shared contracts, package-resource path helpers, + and existing test conventions; identify compatibility seams without editing. +2. Add red focused tests for schema-v2 golden/invalid cases, bounded schema + validation, immutable provenance, descriptor byte order/identity and digest, + compatibility ranges, key lookup/fail-closed trust, and Ed25519 failure modes. +3. Implement the smallest cohesive shared definitions and parser/descriptor/ + signature/trusted-key modules within the ownership boundary. +4. Run the owned focused suite, relevant existing manifest/package regressions, + typecheck, scoped/full lint, and diff/doc gates; investigate only failures + attributable to this ticket. +5. Update this record with the actual outcome and verification ledger, run + task-aware drift, complete `task_context.py`, and return one clean source + commit whose sole parent is the exact dispatch base. + +## Outcome + +- Implemented MLM-01 from the exact `4d8b1fcec0a751d2935effc7816c7e59f568ec65` + frontier. Schema 1 remains bundled/Data Service compatible while schema 2 now + parses immutable `skill_only` and declarative `platform_hosted` definitions, + including the closed bounded JSON Schema subset, hosted permission namespace, + tool/operation uniqueness, and trusted package metadata. +- Added the fixed compact release descriptor serializer/parser, strict SemVer + range comparison, Ed25519 descriptor verifier, and a code-owned fail-closed + signing-key loader. Injected key stores remain an explicit test/service seam; + no environment key override or production private key was added. +- The official MakeLore Ed25519 public key has not been supplied. Production + signing activation therefore remains an explicit hold represented by the empty + code-owned trust table and `PLUGIN_SIGNING_KEY_ACTIVATION_HOLD`; injected-key + verification is covered and is not presented as production activation. +- No network, Package Store, account/project, registry, Pi/Host, Renderer, + billing, hosted runtime, server, Operations, or Release B code was changed. + +## Verification + +- Before each check, the concrete failure and follow-up action were defined: + - `check_project_docs.py`: detect malformed/missing project-docs concurrency + assets; PASS before planning and implementation. + - `task_context.py status --json`: detect owner/base/worktree/branch drift; + PASS for this task and exact dispatch base. + - Initial focused red run: detect missing contract modules; it failed as + expected before implementation, then became green after the modules landed. + - `vitest run tests/unit/coding-plugin-marketplace-contract.test.ts`: detect + schema/descriptor/trust regressions; PASS, 19 tests. + - Existing manifest/Data Service/Pi resource suite: detect schema-1 and + packaged-resource regressions; PASS, 4 files/46 tests. + - Registry/adapter/Pi extension host/bundle/tool suite: detect shared contract + projection regressions; PASS, 5 files/39 tests. + - `pnpm run typecheck`: detect TypeScript contract/import errors; PASS. + - Scoped ESLint: one empty-interface error was fixed in the task-owned + manifest options type; rerun PASS with no errors. + - `pnpm run lint:check`: detect repository lint errors; PASS with five + pre-existing React warnings and zero errors. + - `pnpm run build:vite`: detect renderer/Electron build regressions from the + shared contract changes; PASS with only the repository's existing chunk and + dynamic-import warnings. + - `pnpm test`: detect cross-repository client unit regressions; PASS, 202 test + files/1713 passed/2 skipped, followed by pressure suite 1/1. + - `git diff --check`: detect whitespace/boundary corruption; PASS. +- Final task-aware `check_doc_drift.py` and `task_context.py complete` are the + remaining handoff gates and will be run before the source commit. + +## Follow-ups + +- Supply the official platform Ed25519 public key through the code-owned release + resource before production activation; keep the deployment private key only in + the Works Square deployment secret. +- MLM-02 may consume these primitives after this single source commit is merged + into the client coordinator frontier; its Package Store must compare requested + Release metadata before atomically installing bytes. + +## Promotion Candidates + +- None. This feature task did not modify canonical project memory or accepted + decisions. diff --git a/electron/coding-plugins/manifest.ts b/electron/coding-plugins/manifest.ts index e82090c..66629be 100644 --- a/electron/coding-plugins/manifest.ts +++ b/electron/coding-plugins/manifest.ts @@ -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; 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 { + 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 { +export async function loadCodingPluginDefinition( + packageRoot: string, + options: CodingPluginLoadOptions = {}, +): Promise { 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 { 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), diff --git a/electron/coding-plugins/release-descriptor.ts b/electron/coding-plugins/release-descriptor.ts new file mode 100644 index 0000000..c3f289e --- /dev/null +++ b/electron/coding-plugins/release-descriptor.ts @@ -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 { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function assertDescriptorInputKeys(value: Record, includeSchema: boolean): void { + const allowed = new Set([ + ...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, 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; diff --git a/electron/coding-plugins/signature-verifier.ts b/electron/coding-plugins/signature-verifier.ts new file mode 100644 index 0000000..3e9d156 --- /dev/null +++ b/electron/coding-plugins/signature-verifier.ts @@ -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; + 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, + 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; +} + +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; diff --git a/electron/coding-plugins/trusted-keys.ts b/electron/coding-plugins/trusted-keys.ts new file mode 100644 index 0000000..f5bd2a0 --- /dev/null +++ b/electron/coding-plugins/trusted-keys.ts @@ -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>, +); + +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; diff --git a/shared/coding-plugins.ts b/shared/coding-plugins.ts index cf98da7..b7d63ac 100644 --- a/shared/coding-plugins.ts +++ b/shared/coding-plugins.ts @@ -12,6 +12,23 @@ export type PluginBillingMode = export type PluginToolMutation = 'read' | 'write' | 'destructive'; +export type CodingPluginRuntimeKind = + | 'bundled_typed' + | 'skill_only' + | 'platform_hosted'; + +export type CodingPluginAcquisitionMode = + | 'system_included' + | 'user_acquired'; + +export type CodingPluginExecutionMode = 'synchronous' | 'accepted'; + +export interface CodingPluginPackageProvenance { + readonly source: 'bundled' | 'marketplace'; + /** A trusted Package Store root, never a manifest-provided value. */ + readonly packageRoot: string; +} + export interface CodingPluginSkillDefinition { id: string; entryPath: string; @@ -29,6 +46,8 @@ export interface CodingPluginToolDefinition { projectWriteLease: boolean; permissions: readonly string[]; inputSchema: Readonly>; + outputSchema?: Readonly>; + executionMode?: CodingPluginExecutionMode; } export interface CodingPluginOperationDefinition { @@ -43,6 +62,10 @@ export interface CodingPluginDefinition { contractVersion: number; displayName: string; description: string; + runtimeKind: CodingPluginRuntimeKind; + acquisitionMode: CodingPluginAcquisitionMode; + releaseId: string | null; + provenance: CodingPluginPackageProvenance; scope: 'project'; adapterId: string; requiresBackend: boolean; @@ -404,6 +427,13 @@ export const DATA_SERVICE_PLUGIN_DEFINITION = freezeDeep({ contractVersion: 1, displayName: '开发数据服务', description: '为当前项目提供受控的开发期 JSON 数据存储。', + runtimeKind: 'bundled_typed', + acquisitionMode: 'system_included', + releaseId: null, + provenance: { + source: 'bundled', + packageRoot: 'data-service', + }, scope: 'project', adapterId: DATA_SERVICE_ADAPTER_ID, requiresBackend: true, diff --git a/tests/unit/coding-plugin-marketplace-contract.test.ts b/tests/unit/coding-plugin-marketplace-contract.test.ts new file mode 100644 index 0000000..afbb4de --- /dev/null +++ b/tests/unit/coding-plugin-marketplace-contract.test.ts @@ -0,0 +1,272 @@ +// @vitest-environment node + +import { createHash, generateKeyPairSync, sign } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { + CodingPluginManifestError, + parseCodingPluginManifest, +} from '../../electron/coding-plugins/manifest'; +import { + buildPluginReleaseDescriptor, + isMakeLoreVersionCompatible, + parsePluginReleaseDescriptor, + serializePluginReleaseDescriptor, +} from '../../electron/coding-plugins/release-descriptor'; +import { + createPluginSignatureVerifier, + verifyPluginReleaseSignature, +} from '../../electron/coding-plugins/signature-verifier'; +import { + CODE_OWNED_PLUGIN_SIGNING_KEYS, + createCodeOwnedPluginTrustStore, +} from '../../electron/coding-plugins/trusted-keys'; + +const PACKAGE_ROOT = '/trusted/plugin'; +const ROOT = { + $schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json', + name: 'makelore.example', + version: '1.2.0', + description: 'A declarative example Skill.', + author: { name: 'MakeLore' }, + extensions: { 'com.makelore': { capabilityManifest: './com.makelore/capability.json' } }, +}; + +const SKILL_ONLY_CAPABILITY = { + schemaVersion: 2, + pluginId: 'makelore.example', + contractVersion: 1, + scope: 'project', + runtime: { kind: 'skill_only' }, + skills: [{ id: 'example-skill', entry: '../skills/example-skill/SKILL.md', grants: [] }], + tools: [], +}; + +const HOSTED_CAPABILITY = { + schemaVersion: 2, + pluginId: 'makelore.example', + contractVersion: 1, + scope: 'project', + runtime: { kind: 'platform_hosted', protocol: 'makelore-hosted.v1' }, + skills: [{ id: 'example-skill', entry: '../skills/example-skill/SKILL.md', grants: ['example.generate'] }], + tools: [{ + name: 'example_generate', + label: 'Generate example', + description: 'Generate one example.', + capabilityId: 'example.generate', + operation: 'generate', + roles: ['parent'], + mutation: 'write', + projectWriteLease: false, + permissions: ['hosted.example.generate'], + executionMode: 'synchronous', + inputSchema: { + type: 'object', + additionalProperties: false, + required: ['prompt'], + properties: { prompt: { type: 'string', minLength: 1, maxLength: 4000 } }, + }, + outputSchema: { + type: 'object', + additionalProperties: false, + required: ['text'], + properties: { text: { type: 'string', maxLength: 20000 } }, + }, + }], +}; + +function parse(capability: Record) { + return parseCodingPluginManifest(ROOT, capability, { + packageRoot: PACKAGE_ROOT, + rootManifestPath: `${PACKAGE_ROOT}/plugin.json`, + capabilityManifestPath: `${PACKAGE_ROOT}/com.makelore/capability.json`, + runtimeKind: capability === SKILL_ONLY_CAPABILITY ? 'skill_only' : 'platform_hosted', + acquisitionMode: 'user_acquired', + releaseId: 'release-1', + provenance: { source: 'marketplace', packageRoot: PACKAGE_ROOT }, + }); +} + +describe('Marketplace Release A package contract', () => { + it('parses immutable skill-only schema 2 with trusted provenance', () => { + const definition = parse(SKILL_ONLY_CAPABILITY); + expect(definition).toMatchObject({ + id: 'makelore.example', + version: '1.2.0', + runtimeKind: 'skill_only', + acquisitionMode: 'user_acquired', + releaseId: 'release-1', + provenance: { source: 'marketplace', packageRoot: PACKAGE_ROOT }, + skills: [{ id: 'example-skill', grants: [] }], + tools: [], + operations: [], + }); + expect(Object.isFrozen(definition)).toBe(true); + expect(Object.isFrozen(definition.skills)).toBe(true); + expect(Object.isFrozen(definition.provenance)).toBe(true); + }); + + it('parses declarative hosted schema 2 and preserves closed schemas', () => { + const definition = parse(HOSTED_CAPABILITY); + expect(definition).toMatchObject({ + runtimeKind: 'platform_hosted', + acquisitionMode: 'user_acquired', + requiresBackend: true, + tools: [{ + executionMode: 'synchronous', + outputSchema: HOSTED_CAPABILITY.tools[0]?.outputSchema, + projectWriteLease: false, + }], + operations: [{ capabilityId: 'example.generate', operation: 'generate', toolName: 'example_generate' }], + }); + expect(Object.isFrozen(definition.tools[0]?.outputSchema)).toBe(true); + }); + + it('requires the exact capability manifest path even for direct parsing', () => { + const root = structuredClone(ROOT) as Record; + const extensions = root.extensions as Record; + const makeLore = extensions['com.makelore'] as Record; + makeLore.capabilityManifest = './com.makelore/alternate.json'; + expect(() => parseCodingPluginManifest(root, SKILL_ONLY_CAPABILITY, { + packageRoot: PACKAGE_ROOT, + rootManifestPath: `${PACKAGE_ROOT}/plugin.json`, + capabilityManifestPath: `${PACKAGE_ROOT}/com.makelore/capability.json`, + })).toThrow(CodingPluginManifestError); + }); + + it.each([ + ['unknown capability field', (value: Record) => { value.unknown = true; }], + ['unknown runtime field', (value: Record) => { + (value.runtime as Record).unknown = true; + }], + ['schema-2 adapter privilege', (value: Record) => { value.adapterId = 'data-service'; }], + ['schema-2 local surface privilege', (value: Record) => { value.surfaces = {}; }], + ['skill-only grants', (value: Record) => { + (value.skills as Array>)[0]!.grants = ['example.generate']; + }], + ['skill-only tools', (value: Record) => { + value.tools = [{ ...HOSTED_CAPABILITY.tools[0] }]; + }], + ['hosted local permission', (value: Record) => { + (value.tools as Array>)[0]!.permissions = ['project.data.read']; + }], + ['hosted foreign permission namespace', (value: Record) => { + (value.tools as Array>)[0]!.permissions = ['hosted.makelore.example.generate']; + }], + ['hosted project lease', (value: Record) => { + (value.tools as Array>)[0]!.projectWriteLease = true; + }], + ['unbounded schema', (value: Record) => { + const tool = (value.tools as Array>)[0]!; + (tool.inputSchema as Record).properties = { + prompt: { type: 'string' }, + }; + }], + ['implicit required schema', (value: Record) => { + const tool = (value.tools as Array>)[0]!; + delete (tool.inputSchema as Record).required; + }], + ])('rejects %s', (_label, mutate) => { + const source = structuredClone( + _label === 'skill-only grants' || _label === 'skill-only tools' + ? SKILL_ONLY_CAPABILITY + : HOSTED_CAPABILITY, + ) as Record; + mutate(source); + expect(() => parse(source)).toThrow(CodingPluginManifestError); + }); + + it('keeps the bundled schema-1 definition byte-compatible', async () => { + const { readFile } = await import('node:fs/promises'); + const path = await import('node:path'); + const packageRoot = path.resolve('resources/coding-plugins/data-service'); + const root = JSON.parse(await readFile(path.join(packageRoot, 'plugin.json'), 'utf8')) as Record; + const capability = JSON.parse(await readFile(path.join(packageRoot, 'com.makelore/capability.json'), 'utf8')) as Record; + const definition = parseCodingPluginManifest(root, capability, { + packageRoot, + capabilityManifestPath: path.join(packageRoot, 'com.makelore/capability.json'), + }); + expect(definition.id).toBe('makelore.data-service'); + expect(definition.runtimeKind).toBe('bundled_typed'); + expect(definition.acquisitionMode).toBe('system_included'); + expect(definition.tools).toHaveLength(10); + expect(definition.operations).toHaveLength(14); + }); + + it('serializes and parses the fixed descriptor bytes in field order', () => { + const descriptor = buildPluginReleaseDescriptor({ + pluginId: 'makelore.example', + version: '1.2.0', + packageSchemaVersion: 2, + contractVersion: 1, + minMakeloreVersion: '1.0.0', + maxMakeloreVersion: null, + artifact: { sha256: 'a'.repeat(64), sizeBytes: 1234 }, + }); + const bytes = serializePluginReleaseDescriptor(descriptor); + expect(Buffer.from(bytes).toString('utf8')).toBe( + '{"schema":"makelore-plugin-release.v1","plugin_id":"makelore.example","version":"1.2.0","package_schema_version":2,"contract_version":1,"min_makelore_version":"1.0.0","max_makelore_version":null,"artifact":{"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size_bytes":1234}}', + ); + expect(parsePluginReleaseDescriptor(bytes)).toEqual(descriptor); + expect(() => parsePluginReleaseDescriptor( + Buffer.from(Buffer.from(bytes).toString('utf8').replace('"schema":"makelore-plugin-release.v1"', '"schema":"other.v1"')), + )).toThrow(); + }); + + it('uses inclusive strict SemVer client bounds', () => { + expect(isMakeLoreVersionCompatible('1.0.0', '1.0.0', null)).toBe(true); + expect(isMakeLoreVersionCompatible('2.0.0', '1.0.0', '2.0.0')).toBe(true); + expect(isMakeLoreVersionCompatible('2.0.1', '1.0.0', '2.0.0')).toBe(false); + expect(isMakeLoreVersionCompatible('1.0.0-alpha.1', '1.0.0', null)).toBe(false); + }); + + it('verifies Ed25519 signatures only through the injected test key dependency', () => { + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + const artifact = Buffer.from('artifact-bytes'); + const descriptor = buildPluginReleaseDescriptor({ + pluginId: 'makelore.example', version: '1.2.0', packageSchemaVersion: 2, + contractVersion: 1, minMakeloreVersion: '1.0.0', maxMakeloreVersion: null, + artifact: { sha256: createHash('sha256').update(artifact).digest('hex'), sizeBytes: artifact.length }, + }); + const descriptorBytes = serializePluginReleaseDescriptor(descriptor); + const signature = sign(null, descriptorBytes, privateKey).toString('base64url'); + const verifier = createPluginSignatureVerifier({ + keyStore: new Map([['test-key', publicKey.export({ type: 'spki', format: 'der' })]]), + clientVersion: '1.0.0', + }); + expect(verifyPluginReleaseSignature({ + verifier, keyId: 'test-key', signature, descriptor, artifact, + })).toEqual({ ok: true }); + expect(verifyPluginReleaseSignature({ + verifier, keyId: 'missing-key', signature, descriptor, artifact, + })).toMatchObject({ ok: false, code: 'plugin_signature_invalid' }); + expect(createCodeOwnedPluginTrustStore().get('missing-key')).toBeNull(); + expect(Object.keys(CODE_OWNED_PLUGIN_SIGNING_KEYS)).toHaveLength(0); + }); + + it('rejects bad signature, digest, descriptor identity, and incompatible client', () => { + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + const artifact = Buffer.from('artifact-bytes'); + const descriptor = buildPluginReleaseDescriptor({ + pluginId: 'makelore.example', version: '1.2.0', packageSchemaVersion: 2, + contractVersion: 1, minMakeloreVersion: '2.0.0', maxMakeloreVersion: null, + artifact: { sha256: createHash('sha256').update(artifact).digest('hex'), sizeBytes: artifact.length }, + }); + const signature = sign(null, serializePluginReleaseDescriptor(descriptor), privateKey).toString('base64url'); + const verifier = createPluginSignatureVerifier({ + keyStore: new Map([['test-key', publicKey.export({ type: 'spki', format: 'der' })]]), + clientVersion: '1.0.0', + }); + expect(verifyPluginReleaseSignature({ verifier, keyId: 'test-key', signature, descriptor, artifact })) + .toMatchObject({ ok: false, code: 'plugin_incompatible_client' }); + const compatible = createPluginSignatureVerifier({ + keyStore: new Map([['test-key', publicKey.export({ type: 'spki', format: 'der' })]]), + clientVersion: '2.0.0', + }); + expect(verifyPluginReleaseSignature({ + verifier: compatible, keyId: 'test-key', signature: `${signature.slice(0, -2)}aa`, descriptor, artifact, + })).toMatchObject({ ok: false, code: 'plugin_signature_invalid' }); + expect(verifyPluginReleaseSignature({ + verifier: compatible, keyId: 'test-key', signature, descriptor, artifact: Buffer.from('other'), + })).toMatchObject({ ok: false, code: 'plugin_artifact_invalid' }); + }); +});