diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a576816..2e0f8f8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -206,6 +206,19 @@ export { generatePluginToken, wirePlugins, hasInstalledPlugins, + installFromGithub, + installFromNpm, + installFromSpec, + uninstallPlugin, + verifyEntrySignature, + isRevoked, + fetchIndex, + fetchRevoked, + resolveEntry, + loadMarketplaceConfig, + saveMarketplaceConfig, + addMarketplace, + marketplacesPath, type PluginManifest, type InstalledPlugin, type PluginTrust, @@ -220,6 +233,12 @@ export { type WiredPlugin, type WireResult, type PluginCapabilityBridge, + type RemoteInstallOpts, + type MarketplaceEntry, + type MarketplaceIndex, + type RevokedEntry, + type RevokedList, + type MarketplaceConfig, } from './plugins/index.js'; // Auto-mode classifier (M3c-rest — LLM-judged tool gate when mode === 'auto') diff --git a/packages/core/src/plugins/index.ts b/packages/core/src/plugins/index.ts index a1129ed..6fae25d 100644 --- a/packages/core/src/plugins/index.ts +++ b/packages/core/src/plugins/index.ts @@ -56,3 +56,28 @@ export { type WireResult, type PluginCapabilityBridge, } from './wireup.js'; + +export { + installFromGithub, + installFromNpm, + installFromSpec, + uninstallPlugin, + type RemoteInstallOpts, +} from './install.js'; + +export { + verifyEntrySignature, + isRevoked, + fetchIndex, + fetchRevoked, + resolveEntry, + loadMarketplaceConfig, + saveMarketplaceConfig, + addMarketplace, + marketplacesPath, + type MarketplaceEntry, + type MarketplaceIndex, + type RevokedEntry, + type RevokedList, + type MarketplaceConfig, +} from './marketplace.js'; diff --git a/packages/core/src/plugins/install.ts b/packages/core/src/plugins/install.ts new file mode 100644 index 0000000..67ba9b6 --- /dev/null +++ b/packages/core/src/plugins/install.ts @@ -0,0 +1,147 @@ +// Plugin install — git clone (gh:user/repo) + npm (pkg@npm) + marketplace install paths. +// Spec: docs/DEVELOPMENT_PLAN.md §3.14 (M5.2) +// +// Three install sources: +// 1. Local path (M5; see installLocal in manifest.ts) +// 2. gh:user/repo (M5.2; git clone into staging + verify + move) +// 3. @npm (M5.2; `npm pack` + extract + verify) + +import { spawn } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { installLocal, pluginsDir, type InstalledPlugin } from './manifest.js'; + +export interface RemoteInstallOpts { + /** Override HOME for tests. */ + home?: string; + /** Override the parent dir for staging clones. */ + stagingDir?: string; + /** Trust origin label — recorded in plugins-trust.json. */ + trustedBy?: 'user' | 'marketplace' | 'official'; +} + +/** + * Install from a GitHub repo (`gh:owner/repo` or `gh:owner/repo@ref`). + * Steps: + * 1. git clone --depth 1 [--branch ] into staging dir + * 2. installLocal(staging) → copies to ~/.deepcode/plugins// + * 3. Remove staging dir + */ +export async function installFromGithub( + spec: string, + opts: RemoteInstallOpts = {}, +): Promise { + const m = /^gh:([\w-]+)\/([\w.-]+)(?:@(.+))?$/.exec(spec); + if (!m) throw new Error(`Invalid GitHub spec: ${spec} (expected gh:owner/repo[@ref])`); + const [, owner, repo, ref] = m; + const url = `https://github.com/${owner}/${repo}.git`; + const staging = await fs.mkdtemp( + join(opts.stagingDir ?? tmpdir(), `dc-plug-staging-${repo}-`), + ); + try { + const args = ['clone', '--depth', '1']; + if (ref) args.push('--branch', ref); + args.push(url, staging); + await runCommand('git', args); + return await installLocal({ + sourcePath: staging, + home: opts.home, + trustedBy: opts.trustedBy ?? 'user', + }); + } finally { + await fs.rm(staging, { recursive: true, force: true }); + } +} + +/** + * Install from an npm package (`@npm` or `@@npm`). + * Uses `npm pack ` to produce a tarball, extracts it, and runs the + * local install flow. Doesn't write to the global npm registry. + */ +export async function installFromNpm( + spec: string, + opts: RemoteInstallOpts = {}, +): Promise { + const m = /^(.+)@npm$/.exec(spec); + if (!m) throw new Error(`Invalid npm spec: ${spec} (expected @npm or @@npm)`); + const pkg = m[1]; + const staging = await fs.mkdtemp( + join(opts.stagingDir ?? tmpdir(), `dc-plug-npm-${pkg.replace(/[@/]/g, '_')}-`), + ); + try { + // npm pack --pack-destination=staging + await runCommand('npm', ['pack', pkg, '--pack-destination', staging]); + // Find the tarball (one .tgz in staging) + const entries = await fs.readdir(staging); + const tarball = entries.find((e) => e.endsWith('.tgz')); + if (!tarball) throw new Error(`npm pack produced no tarball in ${staging}`); + // Extract to staging/extracted/ + const extracted = join(staging, 'extracted'); + await fs.mkdir(extracted, { recursive: true }); + await runCommand('tar', ['-xzf', join(staging, tarball), '-C', extracted]); + // tar yields `package/` inside extracted/ + const pkgRoot = join(extracted, 'package'); + return await installLocal({ + sourcePath: pkgRoot, + home: opts.home, + trustedBy: opts.trustedBy ?? 'user', + }); + } finally { + await fs.rm(staging, { recursive: true, force: true }); + } +} + +/** + * Polymorphic entry point: detects spec format and dispatches. + */ +export async function installFromSpec( + spec: string, + opts: RemoteInstallOpts = {}, +): Promise { + if (spec.startsWith('gh:')) return installFromGithub(spec, opts); + if (spec.endsWith('@npm')) return installFromNpm(spec, opts); + // Otherwise: treat as local path + return installLocal({ + sourcePath: spec, + home: opts.home, + trustedBy: opts.trustedBy ?? 'user', + }); +} + +function runCommand(cmd: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const p = spawn(cmd, args, { stdio: 'pipe' }); + let stderr = ''; + p.stderr.on('data', (c: Buffer) => (stderr += c.toString())); + p.on('error', reject); + p.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`${cmd} ${args.join(' ')} exited ${code}: ${stderr}`)); + }); + }); +} + +/** + * Uninstall — remove the plugin dir from ~/.deepcode/plugins// + * and the trust manifest entry. Idempotent. + */ +export async function uninstallPlugin(name: string, home: string = homedir()): Promise { + const dir = join(pluginsDir(home), name); + let existed = false; + try { + await fs.access(dir); + existed = true; + } catch { + /* nothing to remove */ + } + if (existed) await fs.rm(dir, { recursive: true, force: true }); + // Trust state cleanup + const { loadTrustState, saveTrustState } = await import('./manifest.js'); + const state = await loadTrustState(home); + if (state.plugins[name]) { + delete state.plugins[name]; + await saveTrustState(home, state); + } + return existed; +} diff --git a/packages/core/src/plugins/marketplace.test.ts b/packages/core/src/plugins/marketplace.test.ts new file mode 100644 index 0000000..dc6be04 --- /dev/null +++ b/packages/core/src/plugins/marketplace.test.ts @@ -0,0 +1,215 @@ +import { generateKeyPairSync, sign } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import { createServer, type Server } from 'node:http'; +import { AddressInfo } from 'node:net'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + addMarketplace, + fetchIndex, + fetchRevoked, + isRevoked, + loadMarketplaceConfig, + resolveEntry, + saveMarketplaceConfig, + type MarketplaceEntry, + verifyEntrySignature, +} from './marketplace.js'; + +function makeSignedEntry(name: string, version: string, sourceHash: string): MarketplaceEntry { + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + const payload = Buffer.from(`${name}|${version}|${sourceHash}`, 'utf8'); + const sig = sign(null, payload, privateKey); + return { + name, + version, + sourceHash, + sigBase64: sig.toString('base64'), + publisherPubKey: publicKey.export({ format: 'der', type: 'spki' }).toString('base64'), + publisher: 'tester', + downloadUrl: 'https://example.com/x.tgz', + }; +} + +describe('verifyEntrySignature', () => { + it('accepts a well-signed entry', () => { + const e = makeSignedEntry('demo', '1.0.0', 'abc123'); + expect(verifyEntrySignature(e)).toBe(true); + }); + it('rejects tampered name', () => { + const e = makeSignedEntry('demo', '1.0.0', 'abc'); + e.name = 'evil'; + expect(verifyEntrySignature(e)).toBe(false); + }); + it('rejects tampered version', () => { + const e = makeSignedEntry('demo', '1.0.0', 'abc'); + e.version = '9.9.9'; + expect(verifyEntrySignature(e)).toBe(false); + }); + it('rejects tampered sourceHash', () => { + const e = makeSignedEntry('demo', '1.0.0', 'abc'); + e.sourceHash = 'evil-hash'; + expect(verifyEntrySignature(e)).toBe(false); + }); + it('rejects garbage signature', () => { + const e = makeSignedEntry('demo', '1.0.0', 'abc'); + e.sigBase64 = 'not-a-real-signature'; + expect(verifyEntrySignature(e)).toBe(false); + }); +}); + +describe('isRevoked', () => { + it('matches by name+version+sourceHash', () => { + const e = makeSignedEntry('demo', '1.0.0', 'h1'); + expect( + isRevoked(e, { + version: '1', + entries: [{ name: 'demo', version: '1.0.0', sourceHash: 'h1' }], + }), + ).toBe(true); + }); + it('does NOT match on different hash (e.g. re-released)', () => { + const e = makeSignedEntry('demo', '1.0.0', 'h1'); + expect( + isRevoked(e, { + version: '1', + entries: [{ name: 'demo', version: '1.0.0', sourceHash: 'h2' }], + }), + ).toBe(false); + }); +}); + +describe('fetchIndex / fetchRevoked / resolveEntry', () => { + let server: Server; + let baseUrl: string; + let index: { version: string; entries: MarketplaceEntry[] }; + let revoked: { version: string; entries: unknown[] } = { version: '1', entries: [] }; + beforeEach(async () => { + server = createServer((req, res) => { + if (req.url === '/index.json') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(index)); + return; + } + if (req.url === '/revoked.json') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(revoked)); + return; + } + res.writeHead(404); + res.end(); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', () => r())); + const addr = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${addr.port}/index.json`; + }); + afterEach(async () => { + await new Promise((r) => server.close(() => r())); + }); + + it('fetches an index and returns entries', async () => { + const e = makeSignedEntry('demo', '1.0.0', 'h'); + index = { version: '1', entries: [e] }; + const r = await fetchIndex(baseUrl); + expect(r.entries).toHaveLength(1); + }); + + it('resolveEntry picks the highest version + verifies sig + checks revoked', async () => { + const e1 = makeSignedEntry('demo', '1.0.0', 'h-old'); + const e2 = makeSignedEntry('demo', '2.0.1', 'h-new'); + index = { version: '1', entries: [e1, e2] }; + revoked = { version: '1', entries: [] }; + const picked = await resolveEntry({ marketplaceUrl: baseUrl, name: 'demo' }); + expect(picked.version).toBe('2.0.1'); + }); + + it('resolveEntry honors explicit version', async () => { + const e1 = makeSignedEntry('demo', '1.0.0', 'h1'); + const e2 = makeSignedEntry('demo', '2.0.0', 'h2'); + index = { version: '1', entries: [e1, e2] }; + revoked = { version: '1', entries: [] }; + const picked = await resolveEntry({ marketplaceUrl: baseUrl, name: 'demo', version: '1.0.0' }); + expect(picked.version).toBe('1.0.0'); + }); + + it('resolveEntry refuses revoked', async () => { + const e = makeSignedEntry('demo', '1.0.0', 'h-bad'); + index = { version: '1', entries: [e] }; + revoked = { + version: '1', + entries: [{ name: 'demo', version: '1.0.0', sourceHash: 'h-bad' }], + }; + await expect(resolveEntry({ marketplaceUrl: baseUrl, name: 'demo' })).rejects.toThrow(/revocation/i); + }); + + it('resolveEntry refuses tampered entry', async () => { + const e = makeSignedEntry('demo', '1.0.0', 'h'); + e.sourceHash = 'tampered-hash'; + index = { version: '1', entries: [e] }; + revoked = { version: '1', entries: [] }; + await expect(resolveEntry({ marketplaceUrl: baseUrl, name: 'demo' })).rejects.toThrow(/Signature/); + }); + + it('fetchRevoked treats 404 as empty list', async () => { + // Replace the server with one that 404s revoked.json + await new Promise((r) => server.close(() => r())); + server = createServer((req, res) => { + if (req.url === '/index.json') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ version: '1', entries: [] })); + return; + } + res.writeHead(404); + res.end(); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', () => r())); + const addr = server.address() as AddressInfo; + const url = `http://127.0.0.1:${addr.port}/index.json`; + const r = await fetchRevoked(url); + expect(r.entries).toEqual([]); + }); +}); + +describe('marketplace config', () => { + let home: string; + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'dc-mp-')); + }); + afterEach(async () => { + await rm(home, { recursive: true, force: true }); + }); + + it('roundtrips loadMarketplaceConfig / saveMarketplaceConfig', async () => { + const initial = await loadMarketplaceConfig(home); + expect(initial).toEqual({ marketplaces: {} }); + await saveMarketplaceConfig({ marketplaces: { 'https://x.example/index.json': {} } }, home); + const after = await loadMarketplaceConfig(home); + expect(after.marketplaces).toHaveProperty('https://x.example/index.json'); + }); +}); + +describe('addMarketplace', () => { + let server: Server; + let baseUrl: string; + let home: string; + beforeEach(async () => { + server = createServer((_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ version: '1', entries: [] })); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', () => r())); + const addr = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${addr.port}/index.json`; + home = await mkdtemp(join(tmpdir(), 'dc-mp-add-')); + }); + afterEach(async () => { + await new Promise((r) => server.close(() => r())); + await rm(home, { recursive: true, force: true }); + }); + it('saves URL after fetching the index', async () => { + const cfg = await addMarketplace(baseUrl, { home }); + expect(cfg.marketplaces).toHaveProperty(baseUrl); + }); +}); diff --git a/packages/core/src/plugins/marketplace.ts b/packages/core/src/plugins/marketplace.ts new file mode 100644 index 0000000..1897c63 --- /dev/null +++ b/packages/core/src/plugins/marketplace.ts @@ -0,0 +1,196 @@ +// Plugin marketplace — fetch index, verify ed25519 signatures, enforce revoke list. +// Spec: docs/DEVELOPMENT_PLAN.md §3.14 (M5.2) +// +// The marketplace publishes a single `index.json` containing entries: +// { name, version, sourceHash, sigBase64, publisher, downloadUrl, description } +// +// Verification: +// 1. ed25519 signature over `name|version|sourceHash` with publisher's pubkey +// 2. Revocation list at `revoked.json` keyed by name+version+sourceHash +// +// The trust ladder uses these to color a plugin as "official" / "marketplace" +// / "user-local" / "untrusted". + +import { verify } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +export interface MarketplaceEntry { + name: string; + version: string; + /** SHA-256 hash recorded by the publisher; we re-verify on local install. */ + sourceHash: string; + /** Base64-encoded ed25519 signature over `${name}|${version}|${sourceHash}`. */ + sigBase64: string; + /** Base64-encoded ed25519 public key of the publisher (DER SPKI form). */ + publisherPubKey: string; + /** Free-form publisher label (display only — trust comes from pubkey). */ + publisher: string; + downloadUrl: string; + description?: string; +} + +export interface MarketplaceIndex { + version: '1'; + entries: MarketplaceEntry[]; +} + +export interface RevokedEntry { + name: string; + version: string; + sourceHash: string; + reason?: string; +} + +export interface RevokedList { + version: '1'; + entries: RevokedEntry[]; +} + +export function marketplacesPath(home: string = homedir()): string { + return join(home, '.deepcode', 'marketplaces.json'); +} + +export interface MarketplaceConfig { + /** URL → optional pubkey for that marketplace (so an entry's pubkey is verified to come FROM that source). */ + marketplaces: Record; +} + +/** + * Verify a marketplace entry's ed25519 signature. + * Returns true on success; false on any tamper / invalid signature. + */ +export function verifyEntrySignature(entry: MarketplaceEntry): boolean { + try { + const payload = Buffer.from(`${entry.name}|${entry.version}|${entry.sourceHash}`, 'utf8'); + const sig = Buffer.from(entry.sigBase64, 'base64'); + // node:crypto ed25519 verify requires a KeyObject — derive from raw pubkey + // bytes wrapped in SPKI. The published pubkey is itself DER-SPKI base64. + const pubKeyDer = Buffer.from(entry.publisherPubKey, 'base64'); + const { createPublicKey } = require('node:crypto') as typeof import('node:crypto'); + const pub = createPublicKey({ key: pubKeyDer, format: 'der', type: 'spki' }); + return verify(null, payload, pub, sig); + } catch { + return false; + } +} + +export function isRevoked(entry: MarketplaceEntry, revoked: RevokedList): boolean { + return revoked.entries.some( + (r) => + r.name === entry.name && + r.version === entry.version && + r.sourceHash === entry.sourceHash, + ); +} + +/** + * Fetch an index from a marketplace URL. Returns parsed entries. + * Caller is responsible for verifying signatures (see verifyEntrySignature). + */ +export async function fetchIndex(url: string): Promise { + const res = await fetch(url, { method: 'GET' }); + if (!res.ok) throw new Error(`marketplace index ${url}: HTTP ${res.status}`); + const json = (await res.json()) as MarketplaceIndex; + if (json.version !== '1') throw new Error(`unsupported marketplace index version: ${json.version}`); + return json; +} + +/** + * Fetch revoked.json from the same marketplace base URL. + * If the file is missing (404) we treat it as "no revocations" — silent. + */ +export async function fetchRevoked(baseUrl: string): Promise { + const url = baseUrl.replace(/\/index\.json$/, '/revoked.json'); + try { + const res = await fetch(url); + if (res.status === 404) return { version: '1', entries: [] }; + if (!res.ok) throw new Error(`revoked.json ${url}: HTTP ${res.status}`); + return (await res.json()) as RevokedList; + } catch (err) { + // Network errors → treat as empty list (don't break install flow on transient issues) + if ((err as { code?: string }).code === 'ENOTFOUND') { + return { version: '1', entries: [] }; + } + throw err; + } +} + +/** + * Resolve a marketplace entry: fetch index + revoked, find by name (and + * optional version), verify signature, ensure not revoked. + */ +export async function resolveEntry(args: { + marketplaceUrl: string; + name: string; + version?: string; +}): Promise { + const idx = await fetchIndex(args.marketplaceUrl); + const candidate = idx.entries + .filter((e) => e.name === args.name) + .filter((e) => !args.version || e.version === args.version) + .sort((a, b) => versionCompare(b.version, a.version))[0]; + if (!candidate) throw new Error(`No entry "${args.name}"${args.version ? `@${args.version}` : ''} in ${args.marketplaceUrl}`); + if (!verifyEntrySignature(candidate)) + throw new Error(`Signature verification failed for ${args.name}@${candidate.version}`); + const revoked = await fetchRevoked(args.marketplaceUrl); + if (isRevoked(candidate, revoked)) + throw new Error(`${args.name}@${candidate.version} is in the revocation list — refusing to install`); + return candidate; +} + +function versionCompare(a: string, b: string): number { + const aParts = a.split('.').map((s) => Number(s) || 0); + const bParts = b.split('.').map((s) => Number(s) || 0); + for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { + const av = aParts[i] ?? 0; + const bv = bParts[i] ?? 0; + if (av !== bv) return av - bv; + } + return 0; +} + +/** + * Load the user's marketplace registry (~/.deepcode/marketplaces.json). + * Returns { marketplaces: {} } if missing. + */ +export async function loadMarketplaceConfig( + home: string = homedir(), +): Promise { + try { + const raw = await fs.readFile(marketplacesPath(home), 'utf8'); + return JSON.parse(raw) as MarketplaceConfig; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return { marketplaces: {} }; + } + throw err; + } +} + +export async function saveMarketplaceConfig( + config: MarketplaceConfig, + home: string = homedir(), +): Promise { + const path = marketplacesPath(home); + await fs.mkdir(join(home, '.deepcode'), { recursive: true }); + await fs.writeFile(path, JSON.stringify(config, null, 2) + '\n', 'utf8'); +} + +/** + * Adds a marketplace URL to the user's config. Validates by fetching the + * index (must parse). + */ +export async function addMarketplace( + url: string, + opts: { home?: string; rootPubKey?: string } = {}, +): Promise { + // Side-effect: confirm the index is fetchable + parses + await fetchIndex(url); + const home = opts.home ?? homedir(); + const cfg = await loadMarketplaceConfig(home); + cfg.marketplaces[url] = { rootPubKey: opts.rootPubKey }; + await saveMarketplaceConfig(cfg, home); + return cfg; +}