From b44b14c7dfd582bd829d1f680e4f8517f717b807 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 10 Nov 2026 21:18:08 -0600 Subject: [PATCH 1/2] feat(plugins): install pinned instance packages and declared tools (#1873) --- packages/bus/src/broker.mjs | 33 ++- packages/bus/src/limits.mjs | 4 +- packages/bus/src/model-catalogue.mjs | 4 +- packages/bus/src/organization-context.mjs | 1 + packages/bus/src/proxy-usage.mjs | 4 +- packages/bus/src/refusals.mjs | 1 + packages/bus/src/runtime.mjs | 54 +++-- packages/bus/src/usage.mjs | 4 +- packages/bus/tests/project-runtime.test.mjs | 2 +- packages/cli/src/cli.mjs | 7 +- packages/cli/src/config.mjs | 10 +- packages/cli/src/launcher.mjs | 14 +- packages/cli/src/plugin-install.mjs | 38 ++++ packages/cli/src/plugins.mjs | 2 +- packages/cli/tests/organization-imports.mjs | 6 +- .../cli/tests/organization-imports.test.mjs | 8 + packages/harness/src/bundle.mjs | 2 +- packages/harness/src/runner.mjs | 3 +- packages/harness/src/tools.mjs | 4 +- packages/organization/src/agent-copies.mjs | 18 +- packages/organization/src/composition.mjs | 12 +- packages/organization/src/credentials.mjs | 6 +- .../organization/src/instance-settings.mjs | 2 +- packages/organization/src/launch-settings.mjs | 14 +- packages/organization/src/org-registry.mjs | 3 + .../organization/src/organization-config.mjs | 8 +- packages/organization/src/organization.mjs | 8 +- packages/organization/src/plugin-settings.mjs | 10 +- packages/organization/src/resolve.mjs | 15 +- packages/organization/src/role.mjs | 12 +- packages/organization/src/vocabulary.mjs | 9 + packages/plugins/README.md | 76 +++++++ packages/plugins/src/channel.mjs | 4 +- packages/plugins/src/install.mjs | 107 +++++++++ packages/plugins/src/loader.mjs | 56 ++++- packages/plugins/src/manifest.mjs | 73 ++++++ packages/plugins/src/organization.mjs | 30 +++ packages/plugins/src/pinned.mjs | 94 ++++++++ packages/plugins/src/system-types.mjs | 4 +- packages/plugins/src/theme.mjs | 4 +- packages/plugins/src/tools.mjs | 59 +++++ packages/plugins/tests/helpers.mjs | 3 + packages/plugins/tests/loader.test.mjs | 1 + packages/plugins/tests/pinned.test.mjs | 213 ++++++++++++++++++ 44 files changed, 935 insertions(+), 107 deletions(-) create mode 100644 packages/cli/src/plugin-install.mjs create mode 100644 packages/plugins/src/install.mjs create mode 100644 packages/plugins/src/manifest.mjs create mode 100644 packages/plugins/src/organization.mjs create mode 100644 packages/plugins/src/pinned.mjs create mode 100644 packages/plugins/src/tools.mjs create mode 100644 packages/plugins/tests/pinned.test.mjs diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index f93dfb7bd..1dc43d718 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -9,7 +9,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs'; import { loadRequirements } from '../../tasks/src/requirements.mjs'; import { MERGE, LandingRefusal, readLanding } from './landing.mjs'; -import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs'; +import { pluginName, ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs'; // Kept closed while S1 lands; its vocabulary is the integration authority. export const ACTIONS = Object.freeze([ @@ -533,7 +533,7 @@ function checkBusiness(name, b) { if (!object(a) || !Array.isArray(a.withinRole) || !Array.isArray(a.crossRole)) fail('invalid-authority'); const all = [...a.withinRole, ...a.crossRole]; - if (new Set(all).size !== all.length || all.some((v) => !ACTIONS.includes(v) || GATED.has(v))) + if (new Set(all).size !== all.length || all.some((v) => (!ACTIONS.includes(v) && !pluginName(v)) || GATED.has(v))) fail('invalid-authority'); } if (!Object.hasOwn(b.roles, b.arbiters.technical) || !Object.hasOwn(b.roles, b.arbiters.delivery)) @@ -593,6 +593,7 @@ export class Broker { #builtin; #builtinBinding; #organization; + #pluginService; #credentialVerbs; #proxyInit; #proxyPage; @@ -655,7 +656,7 @@ export class Broker { #processing; #taskTracker; // credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use(). - constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) { + constructor({ store, businesses, pluginService = null, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) { if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business'); // Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store. if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function'); @@ -678,6 +679,7 @@ export class Broker { this.#secretCheck = secretCheck; this.#credentialStatus = credentialStatus; this.#organization = organization; + this.#pluginService = pluginService; // Row 199: the runtime's credential lifecycle (credential-verbs.mjs); it holds the store, not the broker. this.#credentialVerbs = credentialVerbs; this.#proxyInit = proxyInit; @@ -2151,6 +2153,13 @@ export class Broker { } bindLaunch(record) { const { key, session, old, body } = this.#launchProof(record); + const launchRole=this.#business(record.business).roles[record.role]; + const pluginCapability=[...(launchRole.tools ?? []),...(launchRole.authority?.withinRole ?? []),...(launchRole.authority?.crossRole ?? []),...Object.keys(launchRole.credentials ?? {})].find(pluginName); + if(pluginCapability) { + if(!this.#pluginService)throw new BusError('plugin-disabled',pluginName(pluginCapability)); + try { this.#pluginService.check(record.business,record.role); } + catch(e) { throw new BusError(e.code === 'plugin-disabled' ? e.code : 'invalid-request', e.code === 'plugin-disabled' ? e.plugin : undefined); } + } if (!old) this.#processingWork(); if (!old && !this.#harnessOn(record.business, record.harness)) fail('invalid-harness'); if (!old && record.thread !== undefined && this.#archived(record.business, this.#threadRow(session, record.thread).project)) fail('project-archived'); @@ -4668,7 +4677,14 @@ export class Broker { fail('role-end-refused'); } #classification(s, action, target = null) { - if (!ACTIONS.includes(action)) fail('unknown-action'); + if (!ACTIONS.includes(action)) { + const name=pluginName(action); + const held=this.#business(s.business).roles[s.role]?.authority; + if(!name || ![...(held?.withinRole ?? []),...(held?.crossRole ?? [])].includes(action))fail('unknown-action'); + if(!this.#pluginService)throw new BusError('plugin-disabled',name); + let checked;try {checked=this.#pluginService.check(s.business,s.role);}catch{throw new BusError('plugin-disabled',name);} + if(!checked.view.registry.actions.includes(action))throw new BusError('plugin-disabled',name); + } if ( this.#hardWait(s, action, target) || (action === 'role.launch' && this.#business(s.business).launch?.by !== s.role) @@ -5915,6 +5931,15 @@ export class Broker { }).catch(error => { throw this.#refused(s, new BusError(/^proxy-[a-z-]+$/.test(error.code ?? '') ? error.code : 'proxy-init-failed'), request); }); } this.#secretCheck(args); + if (request.verb === 'plugin.tool') { + if(s.human || s.reader || s.tester || s.user || s.console)fail('agent-required'); + keys(args,['tool','arguments'],['tool','arguments']); + this.#store.transaction(()=>this.#agent(s)); + if(!this.#pluginService)throw new BusError('plugin-disabled',pluginName(args.tool) ?? undefined); + return this.#pluginService.call(s.business,s.role,args.tool,args.arguments).then(result=>{ + this.#secretCheck(result);return result; + }).catch(e=>{throw this.#refused(s,new BusError(['plugin-disabled','invalid-request','not-authorized'].includes(e.code)?e.code:'adapter-failed',e.code==='plugin-disabled'?e.plugin:undefined),{verb:'plugin.tool'});}); + } if (request.verb === 'processing.halt' || request.verb === 'processing.resume') return this.#requestProcessing(s, request.verb, structuredClone(args)); if (request.verb === 'processing.status') { diff --git a/packages/bus/src/limits.mjs b/packages/bus/src/limits.mjs index c6ec3ba90..90109f400 100644 --- a/packages/bus/src/limits.mjs +++ b/packages/bus/src/limits.mjs @@ -67,7 +67,7 @@ class Failure extends Error { // lineages, each the current, retiring and earlier ids of one agent. A daily key quota follows // the lineage that holds its id, so it outlives rotation and re-issue. Both run only while a // daily key quota is set and a check is switched on. Without keyLineages they refuse. -export function createLimits({ store, read, log, makeLoader = createLoader, keyForSubject = () => null, +export function createLimits({ store, read, pluginDataRoot = null, log, makeLoader = createLoader, keyForSubject = () => null, keyLineages = () => { throw new Failure('proxy-key-refused'); } }) { if (typeof read !== 'function' || typeof log !== 'function') throw new TypeError('createLimits needs read and log'); const loaders = new Map(); @@ -103,7 +103,7 @@ export function createLimits({ store, read, log, makeLoader = createLoader, keyF return validLineages(value) ? value : null; } const loaderFor = business => { - if (!loaders.has(business)) loaders.set(business, makeLoader({ log })); + if (!loaders.has(business)) loaders.set(business, makeLoader({ log, dataRoot:pluginDataRoot })); return loaders.get(business); }; diff --git a/packages/bus/src/model-catalogue.mjs b/packages/bus/src/model-catalogue.mjs index aaf8f1df9..cc6635bbe 100644 --- a/packages/bus/src/model-catalogue.mjs +++ b/packages/bus/src/model-catalogue.mjs @@ -4,7 +4,7 @@ import { createLoader } from '../../plugins/src/loader.mjs'; const validId = s => typeof s === 'string' && /^[a-z0-9][a-z0-9._-]{0,63}$/.test(s); const validModel = s => typeof s === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/.test(s); const disabled = () => ({ status: 'disabled', observedAt: null, models: [], notices: [] }); -export function createModelCatalogue({ client, settings, now = Date.now, makeLoader = createLoader, proxyLock = null }) { +export function createModelCatalogue({ client, settings, pluginDataRoot = null, now = Date.now, makeLoader = createLoader, proxyLock = null }) { const cache = new Map(), flights = new Map(); let generation = 0, writing = 0; function invalidate() { generation++; cache.clear(); } function begin() { writing++; invalidate(); } @@ -19,7 +19,7 @@ export function createModelCatalogue({ client, settings, now = Date.now, makeLoa try { if (off(id)) return disabled(); const names = [...settings(id)['model-provider']]; - const loader = makeLoader({ log: () => {} }); + const loader = makeLoader({ log: () => {}, dataRoot:pluginDataRoot }); const result = await loader.load({ 'model-provider': names }); if (result.refused.length || loader.snapshot('model-provider').length !== names.length) throw Error(); const c = client(); if (!c) throw Error(); diff --git a/packages/bus/src/organization-context.mjs b/packages/bus/src/organization-context.mjs index f36d02ac7..65722e37d 100644 --- a/packages/bus/src/organization-context.mjs +++ b/packages/bus/src/organization-context.mjs @@ -19,6 +19,7 @@ export function busOrganization(business, resolved) { out.roles[role].network = r.limits.network; out.roles[role].authority = structuredClone(r.limits.authority); // REQ-HARN-2: "web" in the resolved tool limit is the grant for the broker's web verbs. + out.roles[role].tools = [...(r.limits.tools ?? [])]; out.roles[role].web = (r.limits.tools ?? []).includes('web'); // Row 210 (#1707, design D5): resolveInstance leaves host. keys out of the session's // credentials. The bus child uses them, so they come back here from the business entry. diff --git a/packages/bus/src/proxy-usage.mjs b/packages/bus/src/proxy-usage.mjs index 8a55d71e8..08a6ef199 100644 --- a/packages/bus/src/proxy-usage.mjs +++ b/packages/bus/src/proxy-usage.mjs @@ -8,8 +8,8 @@ const KINDS = ['input', 'output', 'cacheWrite', 'cacheRead']; const ID = /^[a-f0-9]{12}$/; const plain = n => n <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(n) : String(n); // makeLoader is the plug-in loader's factory; a test wraps it to see what a module is handed. -export function createProxyUsage({ store, read, client, records, log, now = Date.now, makeLoader = createLoader, enableDrain = false }) { - const loader = makeLoader({ log }); +export function createProxyUsage({ store, read, pluginDataRoot = null, client, records, log, now = Date.now, makeLoader = createLoader, enableDrain = false }) { + const loader = makeLoader({ log, dataRoot:pluginDataRoot }); let due = 0, previous = '', pending = null, closed = false; const states = new Map(); // #778: per enabled business, the batches taken off the proxy queue and then not written, since diff --git a/packages/bus/src/refusals.mjs b/packages/bus/src/refusals.mjs index 0844f1c5f..285a3c726 100644 --- a/packages/bus/src/refusals.mjs +++ b/packages/bus/src/refusals.mjs @@ -8,6 +8,7 @@ const group = (codes, rail, nextStep) => { entries.push([code, guidance]); } }; +group("plugin-disabled", "R3/R6/R10: installed, pinned and enabled plugin capabilities", "Ask the instance owner to verify the installed plugin and the organization owner to enable it; then relaunch the agent."); group( "access-refused access-revoked share-exists share-not-covered share-not-found tester-dev-only tester-off tester-refused tester-required tester-target-not-running thread-not-allowed thread-not-owned", "R7: caller access and scoped session boundary", diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index dc4d398cf..f931dcf0f 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -1,3 +1,5 @@ +import { pluginService } from '../../plugins/src/tools.mjs'; +import { readPluginLock } from '../../plugins/src/pinned.mjs'; import { createRequirementsHost } from '../../tasks/src/builtin.mjs'; import { createRequirementCatalog } from './requirement-catalog.mjs'; import { readPrdMaterial } from '../../project/src/prd.mjs'; @@ -71,10 +73,10 @@ export const ACCESS_SWEEP_MS = 30_000; // web.fetch still works. A plug-in that refuses to load is logged and leaves search off; it never // stops the broker. Under harness-builtin the harness serves web itself, so the broker gets no // service and refuses the web verbs. -export async function webServices(businesses, { log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`), deps } = {}) { +export async function webServices(businesses, { log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`), deps, dataRoot = null } = {}) { const services = new Map(); for (const [name, b] of Object.entries(businesses)) { - const loader = createLoader({ log }); + const loader = createLoader({ log, dataRoot }); await loader.load(b.plugins ?? {}); const [source] = loader.modules('search-source'); // "harness-builtin" is served by the harness itself, not the broker: no broker web verbs. @@ -90,7 +92,7 @@ export async function webServices(businesses, { log = (e) => process.stderr.writ // checks a write runs, which are synchronous. A module that doesn't load, or answers anything but a // list of names, answers null, which refuses the write. The tree is pinned, so a module changes only // with a re-pin, and a re-pin restarts the bus. -export async function harnessLevels({ log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`) } = {}) { +export async function harnessLevels({ log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`), dataRoot = null } = {}) { const levels = new Map(); let names = []; try { @@ -98,8 +100,12 @@ export async function harnessLevels({ log = (e) => process.stderr.write(`plugins } catch (e) { log({ event: 'harness-levels-unread', message: e.message }); } + if (dataRoot !== null) { + try { names = [...new Set([...names,...Object.keys(readPluginLock(dataRoot).plugins).filter(name=>hasModule('harness',name,dataRoot))])]; } + catch { log({event:'harness-levels-unread',message:'installed harness discovery refused'}); } + } for (const name of names) { - const loader = createLoader({ log }); + const loader = createLoader({ log, dataRoot }); if ((await loader.load({ harness: [name] })).refused.length) continue; try { const list = loader.modules('harness')[0].levels(); @@ -291,7 +297,7 @@ export async function startBroker({ if (copies !== null && (typeof copies !== 'object' || Array.isArray(copies) || typeof copies.org !== 'string' || !Object.hasOwn(businesses ?? {}, copies.org) || copies.copies === null || typeof copies.copies !== 'object' || Array.isArray(copies.copies))) throw new BusError('invalid-host-request'); - const loadOrg = id => loadWithCopies(id, { dir: organization.dir, rolesDir: organization.rolesDir, snapshot: copies?.org === id ? copies : null }); + const loadOrg = id => loadWithCopies(id, { dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot, snapshot: copies?.org === id ? copies : null }); if (!Array.isArray(consoleOwners) || consoleOwners.length > 512 || consoleOwners.some((o) => o === null || typeof o !== 'object' || Array.isArray(o) || Object.keys(o).length !== 3 || ['provider', 'iss', 'sub'].some((k) => typeof o[k] !== 'string' || !o[k] || o[k].length > 512))) throw new BusError('invalid-host-request'); @@ -406,20 +412,20 @@ export async function startBroker({ // vikunja:19/305: the plug-in switch through the same human-only path as the limits: the verbs // plugins and plugins.set, logged in changes.jsonl with the actor, a missing module refused. - const plugins=organization?pluginSettings({dir:organization.dir,rolesDir:organization.rolesDir,exists:hasModule}):null; - const levels=organization?await harnessLevels():()=>null; + const plugins=organization?pluginSettings({dir:organization.dir,rolesDir:organization.rolesDir,dataRoot:instanceRoot ?? dataRoot,exists:(kind,name)=>hasModule(kind,name,instanceRoot ?? dataRoot)}):null; + const levels=organization?await harnessLevels({dataRoot:instanceRoot ?? dataRoot}):()=>null; // Row 333 R4 (#1831): the writer's key lineage lookup, ids only, from this bus's own credential // records. A daily key quota moved within one agent's lineage keeps its since and its count. const keyLineage=(business,keyId)=>modelKeyLineage(credentials.status(),business,keyId); const settings=organization?launchSettings({...organization,sessions:()=>readSessions(dataRoot),levels,keyLineage}):null; - modelCatalogues = modelCatalogue ?? (settings ? createModelCatalogue({ client: () => modelProxyClient, settings: id => settings.current(id).plugins ?? {}, proxyLock }) : null); + modelCatalogues = modelCatalogue ?? (settings ? createModelCatalogue({ pluginDataRoot:instanceRoot ?? dataRoot, client: () => modelProxyClient, settings: id => settings.current(id).plugins ?? {}, proxyLock }) : null); // Resolve legacy table entries with the enabled modules' declarations. A // missing or ambiguous mapping never becomes an available route. async function catalogueFor(id, options = {}) { const snapshot = settings.writer.read(id); const current = settings.current(id); const names = current.plugins?.['model-provider'] ?? []; - const loader = makeProviderLoader({log:()=>{}}); + const loader = makeProviderLoader({dataRoot:instanceRoot ?? dataRoot,log:()=>{}}); const loaded = await loader.load({'model-provider': names}); const catalogue = await modelCatalogues.read(id, options); if (loaded.refused.length || settings.writer.read(id).revision !== snapshot.revision) return {...catalogue, status:'unavailable', legacy:{}}; @@ -479,16 +485,16 @@ export async function startBroker({ return { revoked: services, outsideStore: how === 'fire' ? outside : [] }; } // Row 165 (#1662): the human adds and changes role instances through instances and instances.set. - const proxyOrganization = modelKeys ? modelOrganization({ modelKeys, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) }) : null; + const proxyOrganization = modelKeys ? modelOrganization({ modelKeys, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }) }) : null; const instances=organization?instanceSettings({dir:organization.dir,rolesDir:organization.rolesDir,system:organization.system,levels,revoke, ...(proxyOrganization ? { writer: proxyOrganization.writer, revoke: proxyOrganization.revoke(revoke) } : {})}):null; const usageLog = entry => process.stderr.write(`usage: ${JSON.stringify(entry)}\n`); const keyForRun = (business, run) => modelRuns.get(business + ':' + run)?.keyId ?? null; // Row 333 R3 (#1831): the usage view marks key quotas that match no hired agent; limits answers. let orphanedKeys = () => null; - const usage = await createUsage({ store, dataRoot, businesses, log: usageLog, keyForRun, + const usage = await createUsage({ store, dataRoot, pluginDataRoot:instanceRoot ?? dataRoot, businesses, log: usageLog, keyForRun, proxyView: business => proxyUsage?.view(business) ?? null, keyOrphans: business => orphanedKeys(business) }); - proxyUsage = createProxyUsage({ store, log: usageLog, client: async () => { + proxyUsage = createProxyUsage({ store, pluginDataRoot:instanceRoot ?? dataRoot, log: usageLog, client: async () => { if (!modelKeys) return null; await modelKeys.verifyIdentity(); return modelProxyClient; @@ -498,7 +504,7 @@ export async function startBroker({ // Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the limit checks read the organization file at // each ask, so a limit, a reset or a switched-on check takes effect without a restart. const limitLog = entry => process.stderr.write(`limits: ${JSON.stringify(entry)}\n`); - const limits = createLimits({ store, read: id => settings ? settings.current(id) : businesses[id], log: limitLog, + const limits = createLimits({ store, pluginDataRoot:instanceRoot ?? dataRoot, read: id => settings ? settings.current(id) : businesses[id], log: limitLog, keyForSubject: (business, subject) => modelUsageKey(credentials.status(), modelRuns, business, subject), keyLineages: business => modelKeyLineages(credentials.status(), business) }); orphanedKeys = business => limits.orphanedKeys(business); @@ -591,7 +597,7 @@ export async function startBroker({ // receipt-backed addition under the organization lock: the file must still be this business plus that entry, // with the credentials, bots, libraries, roles and prior mappings the bus booted with. Anything else leaves // the product pending. Both readiness records must exist before even the broker's reload begins. - const writer = organization ? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) : null; + const writer = organization ? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }) : null; const known = new Map(Object.entries(businesses).map(([id, b]) => [id, canonicalJson(b.projects ?? {})])); function activateManagedProject({ business: id, project, receipt, organization_revision }) { if (!writer || !booted.has(id) || !Object.hasOwn(businesses, id)) throw new BusError('unknown-business'); @@ -692,6 +698,7 @@ export async function startBroker({ trackerOf.set(id, named ?? ((trackers ? tracked : tasks !== null) ? 'vikunja' : 'store')); } broker = new Broker({ + pluginService: pluginService({dataRoot:instanceRoot ?? dataRoot,read:id=>organization ? loadOrg(id) : businesses[id]}), taskTracker: (id) => trackerOf.get(id), workspaceRead: state => requestWorkspace({ dataRoot, state, events: store.all("SELECT kind,actor_role,subject,body FROM events WHERE business=? AND kind IN ('task.created','task.assigned','session.launched') ORDER BY seq LIMIT 10001", state.business) }), requirementsHost, @@ -760,7 +767,7 @@ export async function startBroker({ // configured reads as unreadable, so requireCitation is true), and thread.messages' `last`. guard: (context, body, options) => libraryModule.guardReply({ ...context, dataRoot }, body, options), replyLimits: id => libraryModule.resolveReplyLimits(organization - ? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }).readRaw(id) : { readable: false }), + ? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }).readRaw(id) : { readable: false }), last: (context, paths) => libraryModule.lastCommits({ ...context, dataRoot }, paths), GuardError: libraryModule.GuardError, }, @@ -791,7 +798,7 @@ export async function startBroker({ known.set(id, canonicalJson(next.projects)); return getProjectRemote(writer, id, args.project); } - if (verb === 'system-types.installed') return installedSystemTypes(); + if (verb === 'system-types.installed') return installedSystemTypes({dataRoot:instanceRoot ?? dataRoot}); if (verb === 'system-types.current') return settings.current(id).plugins?.['system-type'] ?? []; if(verb==='limits')return withTokens(id,settings.read(id)); if(verb==='plugins')return {...plugins.read(id), available:{harness:levels.names ?? []}}; @@ -832,7 +839,10 @@ export async function startBroker({ }); result=settings.setRoutes(id,args,actor); } - else if(verb==='plugins.set'){result=plugins.set(id,args,actor);modelCatalogues?.invalidate();} + else if(verb==='plugins.set') { + if(actor==='tester' && Object.entries(args.plugins ?? {}).some(([kind,value])=>(Array.isArray(value)?value:[value]).some(name=>name!==null && !hasModule(kind,name)))) throw new BusError('human-required'); + result=plugins.set(id,args,actor);modelCatalogues?.invalidate(); + } // Row 194 (#1692, V6): the human's keep and noHire lists, through the instance writer. else if(verb==='veto.set')result=instances.veto(id,args,actor); // Row 298: the human's grant scope, through the instance writer. @@ -868,7 +878,7 @@ export async function startBroker({ return settings; } : null, credentialVerbs: organization - ? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) }) + ? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }) }) : null, // Row 65: a new launch binds only on a harness its business switches on, read from the file // as it stands now; an unreadable file switches none on. @@ -941,7 +951,7 @@ export async function startBroker({ let factory = null; if (name === 'vikunja' && tasks) factory = tasks; else if (name !== 'vikunja' || [...Object.values(businesses)].some((b) => b?.plugins?.['task-tracker'] === 'vikunja')) { - const loader = createLoader({ log: pluginLog }); + const loader = createLoader({ dataRoot:instanceRoot ?? dataRoot, log: pluginLog }); const { refused } = await loader.load({ 'task-tracker': name }); const [module] = loader.modules('task-tracker'); if (!refused.length && module) factory = module.adapter({ trackers: trackers ?? {} }); @@ -963,7 +973,7 @@ export async function startBroker({ return one.handle(cap, verb, args); }; } - const web = await webServices(businesses); + const web = await webServices(businesses,{dataRoot:instanceRoot ?? dataRoot}); // Row 210 (#1707, design D7 step 6): clear what a killed bus child left, before anyone can call. if (runRoot) sweepRunDir(runRoot); // Row 234 (r6 X3): end the enrollments a killed bus child left, before anyone can call. @@ -1216,13 +1226,13 @@ export async function startBroker({ if (vars[prefix + 'model'] !== modelKey || vars[prefix + 'harness'] !== harness) return refused('model-provider-unavailable'); const entry = current.launch?.models?.[modelKey]; if (!entry || canonicalJson(entry) !== canonicalJson(expected ?? null) || entry.provider !== provider || entry.model !== model) return refused('model-provider-unavailable'); - const harnessLoader = createLoader({log:()=>{}}); + const harnessLoader = createLoader({dataRoot:instanceRoot ?? dataRoot,log:()=>{}}); if ((await harnessLoader.load({harness:[harness]})).refused.length) return refused('model-provider-unavailable'); const countsAccount = harnessLoader.modules('harness')[0].countsAccount(); if (typeof countsAccount !== 'boolean' || account !== (countsAccount ? current.launch?.account?.name ?? null : null)) return refused('model-provider-unavailable'); const names = current.plugins?.['model-provider']; if (!Array.isArray(names) || !names.length || (entry.module && !names.includes(entry.module))) return refused('model-provider-unavailable'); - const loader = makeProviderLoader({log:()=>{}}), loaded = await loader.load({'model-provider':entry.module ? [entry.module] : names}); + const loader = makeProviderLoader({dataRoot:instanceRoot ?? dataRoot,log:()=>{}}), loaded = await loader.load({'model-provider':entry.module ? [entry.module] : names}); if (loaded.refused.length) return refused('model-provider-unavailable'); const selected = selectModelProvider(loader.snapshot('model-provider'), entry); if (!selected) return refused('model-provider-unavailable'); diff --git a/packages/bus/src/usage.mjs b/packages/bus/src/usage.mjs index da6ff5de9..c5ece348b 100644 --- a/packages/bus/src/usage.mjs +++ b/packages/bus/src/usage.mjs @@ -53,10 +53,10 @@ export function turnRecords(store, business) { }; }); } -export async function createUsage({ store, businesses, log, keyForRun = () => null, proxyView = () => null, keyOrphans = () => null }) { +export async function createUsage({ store, businesses, pluginDataRoot = null, log, keyForRun = () => null, proxyView = () => null, keyOrphans = () => null }) { const loaders = new Map(); for (const [id, b] of Object.entries(businesses)) { - const loader = createLoader({ log }); + const loader = createLoader({ log, dataRoot:pluginDataRoot }); await loader.load(b.plugins ?? {}); loaders.set(id, loader); } diff --git a/packages/bus/tests/project-runtime.test.mjs b/packages/bus/tests/project-runtime.test.mjs index fb105275e..a2f4100e8 100644 --- a/packages/bus/tests/project-runtime.test.mjs +++ b/packages/bus/tests/project-runtime.test.mjs @@ -41,7 +41,7 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n // Row 236 (#1742, #436b) reads giteaName and parseGiteaUrl for the Gitea grant path. // FED R3 r5 (row 274, #414) reads covers, a user's grant check, from it. // Row 219 reads THREAD_DEFINITIONS from the same vocabulary for creative thread admission. - "import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';", + "import { pluginName, ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';", ]); assert.doesNotMatch(source, /\bimport\s*\(/); assert.doesNotMatch(readFileSync(new URL('../src/proxy-page-vocabulary.mjs', import.meta.url), 'utf8'), /\bimport\b/); diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index e825364c6..3d836473e 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -18,6 +18,7 @@ import { interviewCommand, INTERVIEW_USAGE, northStarCommand, NORTH_STAR_USAGE } import { resolveThread, threadCommand, threadMembers, threadsCommand, THREAD_TALK_USAGE, THREAD_USAGE, THREADS_USAGE } from "./thread.mjs"; import { sessionCommand, SESSION_USAGE } from "./session.mjs"; import { limitsCommand, LIMITS_USAGE } from "./limits.mjs"; +import { pluginInstallCommand } from "./plugin-install.mjs"; import { pluginsCommand, PLUGINS_USAGE } from "./plugins.mjs"; import { proxyCommand } from "./model-proxy.mjs"; import { credentialsCommand, CREDENTIALS_USAGE } from "./credentials.mjs"; @@ -220,7 +221,7 @@ function callFor(parsed, io, deps) { // Only the converted project/thread surface uses a channel. Management stays on callFor. function channelCallFor(parsed, io, deps) { const call = callFor(parsed, io, deps); - const channel = createChannelCaller({ name: "cli", call, + const channel = createChannelCaller({ name: "cli", call, dataRoot:(deps.system ?? loadSystem({env:io.env})).dataRoot, log: entry => io.stderr.write(`mosaic: channel module refused (${entry.code})\n`), }); return Object.assign(async (verb, args = {}) => { @@ -759,6 +760,10 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin, if (verb === 'accounts') return accountsCommand(rest, io, { system: deps.system ?? loadSystem({ env: io.env }), configDir: deps.accountsConfigDir }); if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps)); if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps)); + if (verb === "plugins" && ["install","list","remove"].includes(rest[0])) { + refuseInsideAgent(io.env); + return pluginInstallCommand(rest,io,{system:deps.system ?? loadSystem({env:io.env}),dir:deps.accountsConfigDir}); + } if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps)); // Row 199 (#1697): issue and rotate read the token on stdin. if (verb === "proxy") return proxyCommand(rest, io, { system: () => deps.system ?? loadSystem({ env: io.env }), callFor: p => callFor(p, io, deps), prompt: deps.promptProxyKey }); diff --git a/packages/cli/src/config.mjs b/packages/cli/src/config.mjs index bfabae6fe..36fee7d83 100644 --- a/packages/cli/src/config.mjs +++ b/packages/cli/src/config.mjs @@ -153,10 +153,10 @@ export const consoleOwners = (sso) => // snapshot is plain data that the broker and the launcher re-apply to every later read of the // organization file, so a root file change takes effect at the next boot only. A root file that // refuses hides the root level (design section 6): no root copy, and the organization still boots. -export function copiesAtBoot({ businessId, env = process.env, warn = () => {} }) { +export function copiesAtBoot({ businessId, env = process.env, warn = () => {}, dataRoot = null }) { const dir = configDir(env), roles = rolesDir(env); - loadBusiness(businessId, { dir, rolesDir: roles }); - const { document } = organizationWriter({ dir, rolesDir: roles }).read(businessId); + loadBusiness(businessId, { dir, rolesDir: roles, dataRoot }); + const { document } = organizationWriter({ dir, rolesDir: roles, dataRoot }).read(businessId); let root = null; try { root = loadRootAgents(dir, { rolesDir: roles }); } catch (error) { @@ -164,13 +164,13 @@ export function copiesAtBoot({ businessId, env = process.env, warn = () => {} }) warn(`root agent file refused, so ${businessId} runs no root copies until it is fixed and the bus restarts: ${error.message}`); } const snapshot = materializeCopies({ orgId: businessId, doc: document, root, rolesDir: roles }); - return { snapshot, business: withCopies(document, businessFilePath(businessId, dir), { rolesDir: roles, snapshot }) }; + return { snapshot, business: withCopies(document, businessFilePath(businessId, dir), { rolesDir: roles, snapshot, dataRoot }) }; } export function bootConfig({ system, businessId, orgRoot = system.dataRoot, env = process.env, warn = () => {} }) { return business(() => { const vars = systemVars(system); - const { snapshot, business: b } = copiesAtBoot({ businessId, env, warn }); + const { snapshot, business: b } = copiesAtBoot({ businessId, env, warn, dataRoot:system.dataRoot }); const resolved = {}; for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: vars, business: b, instance }); const config = { diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs index 29d9910f8..e4968e2fe 100644 --- a/packages/cli/src/launcher.mjs +++ b/packages/cli/src/launcher.mjs @@ -261,7 +261,7 @@ export function createLauncher({ } // Row 64 (#1557): where a role's skills are read from; scripts/skill.sh activate fills it. const skillsDir = join(system.instanceRoot ?? dataRoot, "skills-enabled"); - const loadOrg = () => loadWithCopies(host.business, { dir: configDir(env), rolesDir: rolesDir(env), snapshot: copies?.org === host.business ? copies : null }); + const loadOrg = () => loadWithCopies(host.business, { dir: configDir(env), rolesDir: rolesDir(env), dataRoot:system.instanceRoot ?? dataRoot, snapshot: copies?.org === host.business ? copies : null }); let b = loadOrg(); const vars = systemVars(system); @@ -358,9 +358,9 @@ export function createLauncher({ const admissionGeneration = admission().generation; let businessRevision; try { - const snapshot = organizationWriter({dir:configDir(env),rolesDir:rolesDir(env)}).read(host.business); + const snapshot = organizationWriter({dir:configDir(env),rolesDir:rolesDir(env),dataRoot:system.instanceRoot ?? dataRoot}).read(host.business); businessRevision = snapshot.revision; - b = withCopies(snapshot.document, businessFilePath(host.business, configDir(env)), {rolesDir:rolesDir(env), snapshot: copies?.org === host.business ? copies : null}); + b = withCopies(snapshot.document, businessFilePath(host.business, configDir(env)), {rolesDir:rolesDir(env), dataRoot:system.instanceRoot ?? dataRoot, snapshot: copies?.org === host.business ? copies : null}); } catch(e){throw new Refusal("organization-refused",e.message);} if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`); // #1873 A3: an unbound or refused copy never launches. The message names the copy, its project @@ -382,16 +382,16 @@ export function createLauncher({ throw new Refusal("legacy-run-live", `a run of ${instance} with no session is still live`); let resolved; try { - resolved = resolveInstance({ system: vars, business: b, instance }); + resolved = resolveInstance({ system: vars, business: b, instance, enforcePlugins:true, pluginRoot:system.instanceRoot ?? dataRoot }); } catch (e) { - throw new Refusal("unsupported-harness", e.message); + throw new Refusal(e.code === "plugin-disabled" ? "plugin-disabled" : "unsupported-harness", e.code === "plugin-disabled" ? e.plugin : e.message); } // Resolve the business's current provider modules with a fresh loader; a refused // load never inherits a previous business's authority. const providerNames = b.plugins?.['model-provider'] ?? []; const providerModules = new Map(); if (providerNames.length) { - const loader = makeLoader({ log: entry => record({ ...entry, instance }) }); + const loader = makeLoader({ dataRoot:system.instanceRoot ?? dataRoot, log: entry => record({ ...entry, instance }) }); const loaded = await loader.load({ 'model-provider': providerNames }); if (loaded.refused.length) throw new Refusal('model-provider-unavailable', 'model provider module refused'); for (const { name, module } of loader.snapshot('model-provider')) providerModules.set(name, module); @@ -427,7 +427,7 @@ export function createLauncher({ } // One harness through a fresh loader, so a refusal never leaves an earlier load's module in use. const harnessOf = async (m) => { - const loader = makeLoader({ log: (entry) => record({ ...entry, instance }) }); + const loader = makeLoader({ dataRoot:system.instanceRoot ?? dataRoot, log: (entry) => record({ ...entry, instance }) }); const { refused } = await loader.load({ harness: [m.harness] }); if (refused.length) throw new Refusal("harness-refused", `harness ${m.harness}: ${refused.map((r) => `${r.code}: ${r.message}`).join("; ")}`); const module = loader.modules("harness")[0]; diff --git a/packages/cli/src/plugin-install.mjs b/packages/cli/src/plugin-install.mjs new file mode 100644 index 000000000..e42e48ac5 --- /dev/null +++ b/packages/cli/src/plugin-install.mjs @@ -0,0 +1,38 @@ +// Instance-level shell verbs. Intentionally separate from pluginsCommand's bus transport. +import { readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { CliError } from './errors.mjs'; +import { refuseInsideAgent } from './transport.mjs'; +import { configDir } from '../../organization/src/organization-config.mjs'; +import { assertAccountsOwner } from '../../organization/src/accounts.mjs'; +import { withRegistry } from '../../organization/src/org-registry.mjs'; +import { readJsonFile } from '../../organization/src/util.mjs'; +import { installPlugin, removePlugin } from '../../plugins/src/install.mjs'; +import { readPluginLock } from '../../plugins/src/pinned.mjs'; + +export async function pluginInstallCommand(args,io,{system,dir=configDir(io.env)}) { + refuseInsideAgent(io.env); + try { + assertAccountsOwner(dir); + const [verb,value,...rest]=args; + if(!['install','list','remove'].includes(verb))throw new CliError('mosaic plugins install SOURCE --sha256 HASH [--commit FULL_SHA] | list | remove NAME',4); + const opts={}; + if(verb==='install') { + for(let i=0;iio.stdout.write(line+'\n')}); + else result=await withRegistry({configDir:dir,instance:system.instance ?? dir.split('/').pop().replace(/^mosaic-/, '')},()=>{ + // All organization files, including archived organizations, must be readable. The registry + // lock prevents a simultaneous create from disappearing from this check. + let files=[];try{files=readdirSync(join(dir,'businesses'));}catch(e){if(e.code!=='ENOENT')throw e;} + return removePlugin({dataRoot:system.dataRoot,name:value,enabled:name=>files.filter(n=>n.endsWith('.json')).some(n=>{ + const doc=readJsonFile(join(dir,'businesses',n),'organization file'); + return Object.values(doc.plugins ?? {}).flat().includes(name); + })}); + },{write:false}); + io.stdout.write(JSON.stringify(result,null,2)+'\n'); + }catch(e){if(e instanceof CliError)throw e;throw new CliError(`plugin management refused: ${/^[a-z-]+$/.test(e.code ?? '')?e.code:'plugin-refused'}`,3);} +} diff --git a/packages/cli/src/plugins.mjs b/packages/cli/src/plugins.mjs index 9f581cf5a..8d5b14f15 100644 --- a/packages/cli/src/plugins.mjs +++ b/packages/cli/src/plugins.mjs @@ -7,7 +7,7 @@ import { readFileSync } from "node:fs"; import { CliError } from "./errors.mjs"; import { orgSelector } from "./org-selector.mjs"; -export const PLUGINS_USAGE = "mosaic plugins [--business ID] [--json] [--tester] | set --file PATCH [--business ID] [--tester]"; +export const PLUGINS_USAGE = "mosaic plugins [--business ID] [--json] [--tester] | set --file PATCH [--business ID] [--tester] | install SOURCE --sha256 HASH [--commit FULL_SHA] | list | remove NAME"; function parse(args) { const out = { positional: [], flags: new Set(), values: {} }; diff --git a/packages/cli/tests/organization-imports.mjs b/packages/cli/tests/organization-imports.mjs index 30cc53266..233abe7c2 100644 --- a/packages/cli/tests/organization-imports.mjs +++ b/packages/cli/tests/organization-imports.mjs @@ -38,10 +38,12 @@ const IMPORT=new RegExp([ `${DYNAMIC.source}\\s*['"](\\.{1,2}\\/[^'"]+)['"]\\s*\\)`, ].join('|'),'g'); const DYNAMIC_LITERAL=/^import\s*\(\s*(['"])[^'"\n]*\1\s*\)/; -// The plug-in loader's one computed import, allowed only in that file and only in this exact form. It loads +// The plug-in loader's computed imports, allowed only in that file and these exact forms. Core loads // packages/plugins///index.mjs after checking that path, and a real copy ships all of // packages/plugins, so a fixture that never loads a plug-in needs nothing more from it. -const LOADER_FILE='packages/plugins/src/loader.mjs',LOADER_IMPORT=/^import\(pathToFileURL\(expected\)\.href\)/; +// PL1 adds the checked instance package entry with its verified hash as a cache key. Installed +// bytes belong to the instance, not to a release fixture's import closure. +const LOADER_FILE='packages/plugins/src/loader.mjs',LOADER_IMPORT=/^import\(pathToFileURL\(expected\)\.href\)|^import\(pathToFileURL\(entry\)\.href \+ '\?pin=' \+ checked\.manifest\.sha256\)/; const REGEX_AFTER='(,=:[!&|?{};+-*%<>~^'; const inert=(literals,index)=>literals.some(([from,to])=>index>=from&&indexreaddirSync(dir,{withFileTypes:true}).flatMap((e)=>e.isDirectory()?sources(join(dir,e.name)):e.name.endsWith('.mjs')?[join(dir,e.name)]:[]); diff --git a/packages/cli/tests/organization-imports.test.mjs b/packages/cli/tests/organization-imports.test.mjs index 0859b2cc7..0c7672699 100644 --- a/packages/cli/tests/organization-imports.test.mjs +++ b/packages/cli/tests/organization-imports.test.mjs @@ -43,6 +43,14 @@ test('the loader exception holds only for the loader file and its exact computed assert.throws(()=>organizationImportedFiles(tree({[`${ORG}a.mjs`]:"import '../../plugins/src/loader.mjs';\n",'repo/packages/plugins/src/loader.mjs':"await import(x);\n"})),/not a plain string literal/); }); +test('PL1 pinned import exception holds only for the checked loader entry and exact hash cache key',()=>{ + const computed="await import(pathToFileURL(entry).href + '?pin=' + checked.manifest.sha256);\n"; + const at=(file,text=computed)=>tree({[`${ORG}a.mjs`]:`import '../../plugins/src/${file}';\n`,[`repo/packages/plugins/src/${file}`]:text}); + assert.deepEqual(organizationImportedFiles(at('loader.mjs')),['packages/plugins/src/loader.mjs']); + assert.throws(()=>organizationImportedFiles(at('other.mjs')),/not a plain string literal/); + for(const changed of [computed.replace('entry','arbitrary'),computed.replace('?pin=','?unchecked='),computed.replace('checked.manifest.sha256','input')])assert.throws(()=>organizationImportedFiles(at('loader.mjs',changed)),/not a plain string literal/); +}); + test('every static form and a literal dynamic import is found, and the imports of what it finds in turn', ()=>{ const repo=withOrg([ "import x from '../../other/src/from.mjs';", diff --git a/packages/harness/src/bundle.mjs b/packages/harness/src/bundle.mjs index 319dd5ac4..93bdb2257 100644 --- a/packages/harness/src/bundle.mjs +++ b/packages/harness/src/bundle.mjs @@ -41,7 +41,7 @@ export function webPlan(resolved, harness, builtinWeb) { } return { source, builtin: granted && source === "harness-builtin" }; } -export const builtinTools = (tools) => tools.filter((t) => t !== "web"); +export const builtinTools = (tools) => tools.filter((t) => t !== "web" && !t.includes(".")); export const SOURCES = Object.freeze({ piExtension: fileURLToPath(new URL("./pi-extension.mjs", import.meta.url)), claudeGate: fileURLToPath(new URL("./claude-gate.mjs", import.meta.url)), diff --git a/packages/harness/src/runner.mjs b/packages/harness/src/runner.mjs index aca5e5ac7..8db1615b9 100644 --- a/packages/harness/src/runner.mjs +++ b/packages/harness/src/runner.mjs @@ -363,11 +363,12 @@ export function toolServer({ path, tools, client, local }) { if (!tool) return answer({ ok: false, error: "unknown-tool" }); const args = request.args && typeof request.args === "object" && !Array.isArray(request.args) ? request.args : {}; try { - const result = Object.hasOwn(local, tool.verb) ? await local[tool.verb](args, { signal: controller.signal }) : await client.call(tool.verb, args); + const result = Object.hasOwn(local, tool.verb) ? await local[tool.verb](args, { signal: controller.signal }) : await client.call(tool.verb, tool.pluginTool ? {tool:tool.pluginTool,arguments:args} : args); answer({ ok: true, result }); } catch (e) { answer({ ok: false, error: /^[a-z-]{1,64}$/.test(e.code ?? "") ? e.code : "tool-failed", ...refusalGuidance(e.code), + ...(e.code === "plugin-disabled" && /^[a-z][a-z0-9-]{0,31}$/.test(e.reason ?? "") ? {plugin:e.reason} : {}), ...(typeof e.reason === "string" && request.tool === "advance_request" && tool.verb === "request.advance" && e.code === "request-evidence-required" ? { reason: e.reason.slice(0, 300) } : tool.verb === "record.write" && e.code === "project-record-invalid" && typeof e.reason === "string" ? { reason: e.reason.slice(0, 300) } : {}) }); diff --git a/packages/harness/src/tools.mjs b/packages/harness/src/tools.mjs index 53454ee35..06505a755 100644 --- a/packages/harness/src/tools.mjs +++ b/packages/harness/src/tools.mjs @@ -96,7 +96,9 @@ const MESSAGE_CLASSES = ["REQUEST", "ASSIGNMENT", "REVIEW-REQUEST", "REVIEW-RESU // "working.land" go to the host's git socket. export function typedTools({ resolved, arbiter = false, tracker = false, projects = [], webTyped = true, land = false, library = false }) { const held = new Set([...resolved.limits.authority.withinRole, ...resolved.limits.authority.crossRole]); - const tools = []; + const tools = (resolved.pluginTools ?? []).filter(t=>resolved.limits.tools.includes(t.name)).map(t=>({ + name:t.name, verb:"plugin.tool", pluginTool:t.name, action:null, description:t.description, parameters:t.arguments, + })); const add = (name, verb, action, description, parameters) => tools.push({ name, verb, action, description, parameters }); if (held.has("message.send")) { add("send_message", "message.send", "message.send", "Send a message to another role instance, or to \"human\". Your final answer to a message is sent back to its sender for you; use this for anything else. A reply to an answer gets no automatic answer; use class REQUEST to ask a follow-up.", obj({ diff --git a/packages/organization/src/agent-copies.mjs b/packages/organization/src/agent-copies.mjs index 30173e671..706ab9002 100644 --- a/packages/organization/src/agent-copies.mjs +++ b/packages/organization/src/agent-copies.mjs @@ -188,11 +188,11 @@ function projectCopyRefusal(copy, document, levels) { // dropped: the file's own instance wins. Returns a frozen business whose `copies` key is the // snapshot's view, with each copy's state as it is in this document; a business with no copies and // no project agents is plain validateBusinessDocument. -export function withCopies(document, file, { rolesDir, snapshot }) { +export function withCopies(document, file, { rolesDir, snapshot, dataRoot = null }) { const copies = snapshot?.copies ?? {}; - if (Object.keys(copies).length === 0 && !hasProjectAgents(document)) return validateBusinessDocument(document, file, { rolesDir }); + if (Object.keys(copies).length === 0 && !hasProjectAgents(document)) return validateBusinessDocument(document, file, { rolesDir, dataRoot }); // The document's own refusals come first, whole. - validateBusinessDocument(document, file, { rolesDir }); + validateBusinessDocument(document, file, { rolesDir, dataRoot }); const orgId = snapshot?.org ?? document.id; const levels = resolveAgentLevels({ orgId, doc: document, root: { agents: snapshot?.rootAgents ?? {} }, rolesDir }); const unions = {}; @@ -234,25 +234,25 @@ export function withCopies(document, file, { rolesDir, snapshot }) { if (refused) { view[instance] = { ...view[instance], state: "refused", reason: refused }; continue; } const entry = entryOf(copy, copy.project === null && narrows[copy.name] ? narrowVars("root", copy.vars, narrows[copy.name]) : copy.vars); try { - validateBusinessDocument({ ...narrowed, roles: { ...roles, [instance]: entry } }, file, { rolesDir }); + validateBusinessDocument({ ...narrowed, roles: { ...roles, [instance]: entry } }, file, { rolesDir, dataRoot }); join[instance] = entry; } catch (error) { if (error?.name !== "BusinessError") throw error; view[instance] = { ...view[instance], state: "refused", reason: reasonOf(error, file, orgId, document, copy) }; } } - const business = validateBusinessDocument({ ...narrowed, roles: { ...roles, ...join } }, file, { rolesDir }); + const business = validateBusinessDocument({ ...narrowed, roles: { ...roles, ...join } }, file, { rolesDir, dataRoot }); return deepFreeze({ ...business, ...(Object.keys(copies).length ? { copies: view } : {}), unions }); } // The organization with its copies, as a bus or launcher loads it. The plain load keeps // loadBusiness's file checks; when there are copies or project agents, the document is read again // through the organization's own writer and validated with them, and only that second read is used. -export function loadWithCopies(id, { dir, rolesDir, snapshot }) { - const plain = loadBusiness(id, { dir, rolesDir }); - const { document } = organizationWriter({ dir, rolesDir }).read(id); +export function loadWithCopies(id, { dir, rolesDir, snapshot, dataRoot = null }) { + const plain = loadBusiness(id, { dir, rolesDir, dataRoot }); + const { document } = organizationWriter({ dir, rolesDir, dataRoot }).read(id); if ((!snapshot || Object.keys(snapshot.copies ?? {}).length === 0) && !hasProjectAgents(document)) return plain; - return withCopies(document, businessFilePath(id, dir), { rolesDir, snapshot }); + return withCopies(document, businessFilePath(id, dir), { rolesDir, snapshot, dataRoot }); } // Why `instance` can't work in code project `project` of this business, as { code, message }, or diff --git a/packages/organization/src/composition.mjs b/packages/organization/src/composition.mjs index 23b090a05..224b8e17d 100644 --- a/packages/organization/src/composition.mjs +++ b/packages/organization/src/composition.mjs @@ -34,7 +34,7 @@ const AUTHORITY_KEYS = Object.freeze(["withinRole", "crossRole"]); const MESSAGING_KEYS = Object.freeze(["sendTo", "receiveFrom"]); const PERSONALITY_KEYS = Object.freeze(["summary", "traits", "voice"]); const CREDENTIAL_KEYS = Object.freeze(["service", "scopes"]); -const PLUGIN_KEYS = Object.freeze(["name", "tools", "actions", "services"]); +const PLUGIN_KEYS = Object.freeze(["name", "tools", "actions", "services", "roleTemplates"]); const PLUGIN_SERVICE_KEYS = Object.freeze(["name", "scopes"]); // Step 6's length bounds, in characters or entries. The design gives none, so P1 sets them. The @@ -137,7 +137,14 @@ function checkPlugin(plugin, i, core) { const names = [...tools, ...actions, ...services.map((s) => s.name.slice(name.length + 1))]; const twice = names.find((n, j) => names.indexOf(n) !== j); if (twice !== undefined) refuse(`${where} declares ${JSON.stringify(full(twice))} twice`); - return { name, tools: tools.map(full), actions: actions.map(full), services }; + if (plugin.roleTemplates !== undefined && !Array.isArray(plugin.roleTemplates)) refuse("invalid plugin role templates"); + const roleTemplates=(plugin.roleTemplates ?? []).map(t=>{ + if(!isPlain(t) || Object.keys(t).sort().join(",")!=="file,name" || typeof t.name!=="string" || !DECLARED_NAME.test(t.name) || coreName(t.name) + || typeof t.file!=="string" || !/^[a-zA-Z0-9._/-]+$/.test(t.file) || t.file.split("/").some(p=>!p || p==="." || p==="..")) refuse("invalid plugin role template"); + return {name:full(t.name),file:t.file}; + }); + if(new Set(roleTemplates.map(t=>t.name)).size!==roleTemplates.length)refuse("duplicate plugin role template"); + return { name, tools: tools.map(full), actions: actions.map(full), services, roleTemplates }; } // The registry checkComposition reads: the core set plus each plugin's declared set. The plugin @@ -162,6 +169,7 @@ export function buildRegistry({ plugins = [] } = {}) { routed: [...ROUTED, ...pluginActions], services, plugins: declared, + roleTemplates: declared.flatMap(p=>p.roleTemplates), }); BUILT.add(registry); return registry; diff --git a/packages/organization/src/credentials.mjs b/packages/organization/src/credentials.mjs index 52f95c085..02e53e219 100644 --- a/packages/organization/src/credentials.mjs +++ b/packages/organization/src/credentials.mjs @@ -8,6 +8,8 @@ import { refuse } from "./errors.mjs"; import { credentialKind, isHostKind } from "./vocabulary.mjs"; import { requireObject, rejectUnknownKeys, requireDate } from "./util.mjs"; +import { pluginName } from "../../plugins/src/organization.mjs"; + const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/; export const DATE_KEY = Object.freeze({ gitea: "rotateBy", vikunja: "expires" }); const WARN_DAYS = 7; @@ -21,10 +23,10 @@ const DAY_MS = 24 * 60 * 60 * 1000; // can't use the form. A `file` reference into the agents' store must be the // agent's own path (`credential-not-yours`). export function parseCredentialRef(ref, service, where, { store = null } = {}) { - if (!credentialKind(service)) refuse(`${where}: unknown credential service ${JSON.stringify(service)}`); + if (!credentialKind(service) && !pluginName(service)) refuse(`${where}: unknown credential service ${JSON.stringify(service)}`); requireObject(ref, where); // Row 210 (#1707, design D1): an ssh key doesn't expire, so host. takes rotateBy as gitea does. - const dateKey = isHostKind(service) ? "rotateBy" : DATE_KEY[service]; + const dateKey = isHostKind(service) ? "rotateBy" : (DATE_KEY[service] ?? "rotateBy"); rejectUnknownKeys(ref, ["file", "env", "store", dateKey], where); const hasFile = ref.file !== undefined; const hasEnv = ref.env !== undefined; diff --git a/packages/organization/src/instance-settings.mjs b/packages/organization/src/instance-settings.mjs index 2859c0ce6..0044fab86 100644 --- a/packages/organization/src/instance-settings.mjs +++ b/packages/organization/src/instance-settings.mjs @@ -150,7 +150,7 @@ function createdCredentials(old, instance, definition, args) { // services) revokes the instance's credentials for a fire or a delete and throws // credential-revoke-failed when it can't. Null in a writer with no bus (the CLI's), which revokes // nothing. Row 239 (#1745): writer is the organization writer, injected only by tests. -export function instanceSettings({ dir, rolesDir, system, levels = () => null, revoke = null, writer = organizationWriter({ dir, rolesDir }) }) { +export function instanceSettings({ dir, rolesDir, system, levels = () => null, revoke = null, writer = organizationWriter({ dir, rolesDir, dataRoot:system?.dataRoot ?? null }) }) { // The definitions in rolesDir and whether add and set accept each one. function definitions() { diff --git a/packages/organization/src/launch-settings.mjs b/packages/organization/src/launch-settings.mjs index 8830f26c1..bba60a9c0 100644 --- a/packages/organization/src/launch-settings.mjs +++ b/packages/organization/src/launch-settings.mjs @@ -168,18 +168,18 @@ const ROUTE_FIELDS = Object.freeze(["harness", "model", "thinking"]); // Without it, a write that adds a daily key quota refuses proxy-key-refused; no other write asks. export function launchSettings({ dir, rolesDir, system, sessions = () => [], now = () => new Date().toISOString(), levels = () => null, keyLineage = () => { throw new OrganizationError("proxy-key-refused"); } }) { - const writer = organizationWriter({ dir, rolesDir }); + const writer = organizationWriter({ dir, rolesDir, dataRoot:system?.dataRoot ?? null }); // The validated business as the file stands now (row 125: the limit checks read it at each ask). function current(id) { - return validateBusinessDocument(writer.read(id).document, businessFilePath(id, dir), { rolesDir }); + return validateBusinessDocument(writer.read(id).document, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null }); } // The file before a write, validated, for checkRoutes; null when it no longer validates, so every // route is checked. function before(id, document) { try { - return validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir }); + return validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null }); } catch (e) { if (e.name === "BusinessError") return null; throw e; @@ -188,7 +188,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now function view(id) { const { document, revision } = writer.read(id); - const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir }); + const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null }); if (!business.launch) throw new OrganizationError("launch-not-configured"); const instances = Object.fromEntries(Object.keys(business.roles).map(instance => { const vars = resolveInstance({ business, system, instance }).vars; @@ -305,7 +305,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now if (next.roles[instance]?.definition !== entry.definition) throw new OrganizationError("organization-definition-refused"); } // Check every instance's route before publishing the complete document. - const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir }); + const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null }); checkRoutes({ proposed, old: before(id, old.document), levels, catalogue }); const receipt = writer.write(id, next, { revision: args.revision, actor }); return { ...view(id), transaction: receipt.transaction }; @@ -317,7 +317,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now // doesn't set reads null; its launch refuses no-route. function routes(id) { const { document, revision } = writer.read(id); - const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir }); + const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null }); if (!business.launch) throw new OrganizationError("launch-not-configured"); const harnesses = Array.isArray(business.plugins?.harness) ? [...business.plugins.harness] : []; const named = new Set(harnesses); @@ -397,7 +397,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now next.roles[instance].vars = vars; } if (canonicalJson(next) === canonicalJson(old.document)) throw new OrganizationError("organization-no-change"); - const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir }); + const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null }); checkRoutes({ proposed, old: before(id, old.document), levels, catalogue }); const receipt = writer.write(id, next, { revision: args.revision, actor }); return { ...routes(id), transaction: receipt.transaction }; diff --git a/packages/organization/src/org-registry.mjs b/packages/organization/src/org-registry.mjs index b2c3b17fb..6bd757909 100644 --- a/packages/organization/src/org-registry.mjs +++ b/packages/organization/src/org-registry.mjs @@ -228,3 +228,6 @@ export async function orgLock(ctx, id, { clear = false } = {}) { } } } + +// PL1: the organization primitive set, derived from enabled, verified manifests only. +export { organizationPlugins as buildOrganizationRegistry } from "../../plugins/src/organization.mjs"; diff --git a/packages/organization/src/organization-config.mjs b/packages/organization/src/organization-config.mjs index 8b717234c..95eba2fb2 100644 --- a/packages/organization/src/organization-config.mjs +++ b/packages/organization/src/organization-config.mjs @@ -238,7 +238,7 @@ function checkRetired(retired, roles, file) { // Validate a parsed business document read from `file`. `rolesDir` is where // role definitions live. Returns a frozen business with its definitions. -export function validateOrganizationDocument(document, file, { rolesDir }) { +export function validateOrganizationDocument(document, file, { rolesDir, dataRoot = null }) { requireObject(document, "business file"); rejectUnknownKeys(document, TOP_KEYS, "business file"); if (document.businessVersion !== 1) refuse(`business file "businessVersion" must be 1 (${file})`); @@ -334,7 +334,7 @@ export function validateOrganizationDocument(document, file, { rolesDir }) { const plugins = document.plugins === undefined ? {} : structuredClone(checkPlugins(document.plugins, `${file} plugins`, { fail: (_code, message) => refuse(message) })); for (const id of plugins["system-type"] ?? []) { - if (!hasModule("system-type", id)) refuse(`${file} plugins.system-type names unknown module ${id}`); + if (dataRoot === null && !hasModule("system-type", id)) refuse(`${file} plugins.system-type names unknown module ${id}`); } const usage = document.usage === undefined ? undefined : checkUsageSettings(document.usage); const appearance = checkAppearance(document.appearance, `${file} appearance`); @@ -349,14 +349,14 @@ export function validateOrganizationDocument(document, file, { rolesDir }) { // Load businesses/.json from the config directory. The file holds // authority, so it must belong to this user and not be writable by group // or other. -export function loadOrganization(id, { dir = configDir(), rolesDir }) { +export function loadOrganization(id, { dir = configDir(), rolesDir, dataRoot = null }) { if (!rolesDir) throw new Error("loadBusiness needs rolesDir"); const file = organizationFilePath(id, dir); const document = readJsonFile(file, "business file", (stat) => { if (stat.uid !== process.getuid()) refuse(`business file must belong to uid ${process.getuid()}: ${file}`); if ((stat.mode & 0o022) !== 0) refuse(`business file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`); }); - return validateOrganizationDocument(document, file, { rolesDir }); + return validateOrganizationDocument(document, file, { rolesDir, dataRoot }); } // The old names (rename design r2, module map): the same functions, not copies. diff --git a/packages/organization/src/organization.mjs b/packages/organization/src/organization.mjs index 81e205785..e06e70086 100644 --- a/packages/organization/src/organization.mjs +++ b/packages/organization/src/organization.mjs @@ -140,7 +140,7 @@ export function setReplyLimits(writer, id, args, actor) { return writer.write(id, { ...document, replyLimits }, { revision: args.revision, actor }); } -export function organizationWriter({ dir, rolesDir, barrier = () => {} }) { +export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = () => {} }) { const evidence = join(dir, "businesses", ".history"); const logFile = id => join(evidence, id, "changes.jsonl"); // Keep the writer's established busy refusal when the shared registry lock @@ -155,7 +155,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) { const file = businessFilePath(id, dir); const bytes = regular(file); const document = parseJsonFileText(bytes, file, "organization file"); - validateBusinessDocument(document, file, { rolesDir }); + validateBusinessDocument(document, file, { rolesDir, dataRoot }); return { document, revision: sha256(bytes) }; } @@ -297,7 +297,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) { modelQuotaFence(old, document, actor); usageFence(old, document, actor); systemTypesFence(old, document, actor); - validateBusinessDocument(document, file, { rolesDir }); + validateBusinessDocument(document, file, { rolesDir, dataRoot }); const bytes = JSON.stringify(document, null, 2) + "\n"; // Check and log exactly what lands on disk, for every setter: a value JSON // can't carry (a Date, a Map) serializes as something else (row 131, sage-0314). @@ -307,7 +307,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) { modelQuotaFence(old, written, actor); usageFence(old, written, actor); systemTypesFence(old, written, actor); - validateBusinessDocument(written, file, { rolesDir }); + validateBusinessDocument(written, file, { rolesDir, dataRoot }); if (beforeWrite) { beforeWrite(); revoked = true; diff --git a/packages/organization/src/plugin-settings.mjs b/packages/organization/src/plugin-settings.mjs index 88c383e57..3db969a31 100644 --- a/packages/organization/src/plugin-settings.mjs +++ b/packages/organization/src/plugin-settings.mjs @@ -4,13 +4,15 @@ // Writes go through the organization writer, which logs old and new names // like a limit change. A write is a patch per kind (see set()): kinds it doesn't name are kept. A refused write throws a code and logs nothing. import { organizationWriter, OrganizationError } from "./organization.mjs"; +import { verifiedPlugin } from "../../plugins/src/pinned.mjs"; +import { hasModule } from "../../plugins/src/loader.mjs"; import { isPlainObject } from "./util.mjs"; import { KINDS, PluginError, checkPlugins, checkUsageSettings } from "../../plugins/src/kinds.mjs"; // `exists(kind, name)`, when given, refuses a name no module answers to (the host passes the // loader's hasModule). Without it the setter checks only the shape, as before. -export function pluginSettings({ dir, rolesDir, exists = null }) { - const writer = organizationWriter({ dir, rolesDir }); +export function pluginSettings({ dir, rolesDir, exists = null, dataRoot = null }) { + const writer = organizationWriter({ dir, rolesDir, dataRoot }); function read(id) { const { document, revision } = writer.read(id); @@ -51,6 +53,10 @@ export function pluginSettings({ dir, rolesDir, exists = null }) { if (exists) { for (const [kind, setting] of Object.entries(sets)) { for (const name of Array.isArray(setting) ? setting : [setting]) { + if (dataRoot !== null && !hasModule(kind,name)) { + try { verifiedPlugin(dataRoot,name,kind); } + catch(e) { throw new OrganizationError(e.code === "missing-lock" ? "unknown-plugin" : "invalid-plugins", "installed plugin verification refused"); } + } if (!exists(kind, name)) throw new OrganizationError("unknown-plugin", `no ${kind} module named ${name} in this tree`); } } diff --git a/packages/organization/src/resolve.mjs b/packages/organization/src/resolve.mjs index b698a4fcf..bcf4bd3e7 100644 --- a/packages/organization/src/resolve.mjs +++ b/packages/organization/src/resolve.mjs @@ -3,6 +3,7 @@ // the record a launcher or the broker uses. Plain values: the most specific // layer wins. Limits: every layer narrows, nothing widens. +import { organizationPlugins, assertPluginCapabilities } from "../../plugins/src/organization.mjs"; import { ownRails } from "./rails.mjs"; import { refuse } from "./errors.mjs"; import { ACTIONS, NETWORKS, isHostKind } from "./vocabulary.mjs"; @@ -31,11 +32,18 @@ function narrowerNetwork(a, b) { // business loadBusiness(...) output // project loadProject(...) output, or null // instance a role instance the business file declares -export function resolveInstance({ system, business, project = null, instance }) { +export function resolveInstance({ system, business, project = null, instance, enforcePlugins = false, pluginRoot = system?.dataRoot ?? null }) { const entry = Object.hasOwn(business.roles, instance) ? business.roles[instance] : null; if (!entry) refuse(`business ${business.id} declares no role instance ${JSON.stringify(instance)}`); const definition = business.definitions[entry.definition]; + const pluginView = organizationPlugins(pluginRoot,business.plugins); + let disabledPlugin = null; + try { assertPluginCapabilities(definition,pluginView); } catch(e) { + if(enforcePlugins)throw e; + disabledPlugin=e.plugin; + } + const pluginTools = pluginView.manifests.flatMap(m=>m.tools).filter(t=>definition.tools.includes(t.name)); const layers = [ { layer: "system", source: "system", vars: system }, { layer: "business", source: `business:${business.id}`, vars: business.vars }, @@ -79,6 +87,9 @@ export function resolveInstance({ system, business, project = null, instance }) } const resolved = { + ...(pluginTools.length ? {pluginTools} : {}), + ...(pluginView.registry.plugins.length ? {pluginActions:pluginView.registry.plugins.flatMap(p=>p.actions)} : {}), + ...(disabledPlugin ? {disabledPlugin} : {}), business: business.id, project: projectId, instance, @@ -112,7 +123,7 @@ export function resolveInstance({ system, business, project = null, instance }) // Classify one action for a resolved instance: "within", "cross" or // "gated". An action outside the vocabulary refuses. export function classify(resolved, action) { - if (!ACTIONS.includes(action)) refuse(`unknown action: ${JSON.stringify(action)}`); + if (!ACTIONS.includes(action) && !resolved.pluginActions?.includes(action)) refuse(`unknown action: ${JSON.stringify(action)}`); if (resolved.limits.authority.withinRole.includes(action)) return "within"; if (resolved.limits.authority.crossRole.includes(action)) return "cross"; return "gated"; diff --git a/packages/organization/src/role.mjs b/packages/organization/src/role.mjs index 488ef6c69..4776e8701 100644 --- a/packages/organization/src/role.mjs +++ b/packages/organization/src/role.mjs @@ -15,6 +15,8 @@ import { requireObject, rejectUnknownKeys, requireId, requireString, requireDistinctList, readJsonFile, deepFreeze, } from "./util.mjs"; +import { pluginName } from "../../plugins/src/organization.mjs"; + const CONTRACT_NAME = /^[a-z0-9][a-z0-9._-]{0,60}\.md$/; const V1_KEYS = ["roleVersion", "name", "tools", "network"]; const V2_REQUIRED = ["roleVersion", "name", "title", "contract", "tools", "network", "authority", "credentials"]; @@ -24,7 +26,7 @@ const SKILL_NAME = /^[A-Za-z0-9._-]+$/; function checkTools(tools) { if (!Array.isArray(tools) || tools.length === 0) refuse('role "tools" must be a non-empty array of tool names'); return requireDistinctList(tools, "role tools", (tool) => { - if (!TOOLS.includes(tool)) refuse(`unsupported tool (supported: ${TOOLS.join(", ")})`); + if (!TOOLS.includes(tool) && !pluginName(tool)) refuse(`unsupported tool (supported: ${TOOLS.join(", ")})`); }); } @@ -39,7 +41,7 @@ function checkAuthority(authority) { const lists = {}; for (const key of ["withinRole", "crossRole"]) { lists[key] = requireDistinctList(authority[key], `role authority.${key}`, (action) => { - if (!ACTIONS.includes(action)) refuse(`unknown action in authority.${key}; expected an action from the role vocabulary`); + if (!ACTIONS.includes(action) && !pluginName(action)) refuse(`unknown action in authority.${key}; expected an action from the role vocabulary`); if (GATED_ONLY.includes(action)) refuse(`authority.${key} lists ${action}, which is always gated`); }); } @@ -132,13 +134,15 @@ function checkCredentials(credentials) { // Row 210 (#1707, design D1): "host" takes no scopes; the business file names the hosts. const host = entry.service === HOST_SERVICE; rejectUnknownKeys(entry, host ? ["service"] : ["service", "scopes"], `role credentials[${i}]`); - if (!host && !SERVICES.includes(entry.service)) { + if (!host && !SERVICES.includes(entry.service) && !pluginName(entry.service)) { refuse(`role credentials[${i}].service must be one of: ${[...SERVICES, HOST_SERVICE].join(", ")}`); } if (seen.has(entry.service)) refuse(`role credentials name ${entry.service} twice`); seen.add(entry.service); if (host) return { service: HOST_SERVICE }; - const scopes = entry.service === "gitea" ? checkGiteaScopes(entry.scopes) : checkVikunjaScopes(entry.scopes); + const scopes = pluginName(entry.service) ? requireDistinctList(entry.scopes, "plugin credential scopes", s => { + if (typeof s !== "string" || !/^[a-z][a-z0-9_.:-]{0,63}$/.test(s)) refuse("invalid plugin credential scope"); + }, {nonEmpty:true}) : entry.service === "gitea" ? checkGiteaScopes(entry.scopes) : checkVikunjaScopes(entry.scopes); return { service: entry.service, scopes }; }); } diff --git a/packages/organization/src/vocabulary.mjs b/packages/organization/src/vocabulary.mjs index 8a7de8e66..2adc58192 100644 --- a/packages/organization/src/vocabulary.mjs +++ b/packages/organization/src/vocabulary.mjs @@ -374,3 +374,12 @@ export function grantInScope(scope, body) { } return false; } + +// PL1: a capability's plugin namespace, without loading files or code. Kept pure because the +// runner's bus client imports the broker error type inside the existing sandbox binds. +export function pluginName(capability) { + const core = [...ACTIONS,...TOOLS,...SERVICES,HOST_SERVICE]; + if(typeof capability!=="string" || core.includes(capability) || !/^[a-z][a-z0-9-]*\.[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/.test(capability))return null; + const name=capability.split(".")[0]; + return [...core,...NETWORKS].some(n=>n.split(".")[0]===name) ? null : name; +} diff --git a/packages/plugins/README.md b/packages/plugins/README.md index 30932e95b..54b08a8e6 100644 --- a/packages/plugins/README.md +++ b/packages/plugins/README.md @@ -505,3 +505,79 @@ A harness declares `outputPolicy({ proxy })` as `adapter`, `non-newline-stdout`, from module directories, and malformed modules refuse discovery. An organization selects modules with `plugins.system-type`; an omitted list enables none. The initial five modules declare no toolsets yet. + +### Instance-installed packages (PL1, #1873) + +The instance owner may install trusted plugin code from the host shell: + +``` +mosaic plugins install ./package --sha256 <64 lowercase hex> +mosaic plugins install ./package.zip --sha256 <64 lowercase hex> +mosaic plugins install https://example.invalid/plugin.git --commit <40 lowercase hex> --sha256 <64 lowercase hex> +mosaic plugins list +mosaic plugins remove +``` + +These commands read the selected system config's `dataRoot`, use +`/plugins/`, and publish `/plugins.lock.json`. +They have no bus, Console, organization, tester, or agent installation path. +Removal holds the organization registry lock and refuses if any organization +file still enables the package. Disable it through the existing owner-only +`plugins.set` before removal. The installer prints the names of all host tools +before its first instance write. Plugin code runs as the instance uid; installing +it is a trust decision, and a hash pin does not make code safe. + +Every package has this strict `plugin.json` shape (all fields required): + +```json +{ + "name": "example", + "version": "1.0.0", + "kinds": ["usage-source"], + "interfaceVersions": {"usage-source": 1}, + "sha256": "<64 lowercase hex>", + "source": {"path": "./source", "version": "1.0.0"}, + "signature": null, + "tools": [{ + "name": "example.lookup", + "description": "Look up a local fixture", + "arguments": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}, + "runs": "sandbox", + "entry": "tools/lookup.mjs" + }], + "actions": ["example.publish"], + "services": [{"name": "example.account", "scopes": ["read"]}], + "roleTemplates": [{"name": "example.writer", "file": "templates/writer.json"}] +} +``` + +`source` takes exactly one of `path`/`url` and one of `version`/`commit`. +Git installs record their actual full commit and URL in the lock. `signature` +is reserved and must be null. Kinds and interface versions remain core policy; +each kind has `/index.mjs` with the usual exports. Every declaration has +the plugin namespace, and no declaration may reuse a core name or hard wait. +Templates are recorded as data; installation imports no role and hires nobody. + +`pluginHash(directory)` in `src/pinned.mjs` computes the expected digest. Sort +relative regular-file paths lexically and hash the UTF-8 frame +`:::` followed by each file's bytes. +For `plugin.json`, replace only the top-level `sha256` value with 64 zeroes. +All other bytes, including whitespace and descriptions, are pinned. Symlinks, +hard links, special files, unsafe archive paths and duplicate archive entries +refuse. Files become 0444, directories 0555. Empty directories carry no code +and are omitted from the digest. + +A declared tool exports `execute(arguments)` from its `entry` module. Argument +schemas use the bounded, reference-free subset checked in `src/manifest.mjs`; +unknown keywords refuse. Tool presence follows the resolved role, and the +broker rechecks the current organization setting and hash on every call. +Host tools run in the host process. Sandbox tools run in a fresh bubblewrap +namespace with the pinned package and Node runtime, no home, config, workspace +or network. They receive JSON on stdin and return JSON on stdout. + +The loader checks the complete package against the lock before every installed +module import. A refusal logs static codes and the validated package name; +core modules continue. A bad pin evicts previously loaded installed modules. +A role referencing a missing or disabled plugin refuses at launch with +`plugin-disabled` and the package name. Disabling never fires or rewrites an +agent; its next plugin tool call refuses with that same code. diff --git a/packages/plugins/src/channel.mjs b/packages/plugins/src/channel.mjs index 84c54c091..a4bc95936 100644 --- a/packages/plugins/src/channel.mjs +++ b/packages/plugins/src/channel.mjs @@ -8,10 +8,10 @@ const report = entry => process.stderr.write(`channel: ${JSON.stringify({ event: entry.event, kind: entry.kind, name: entry.name, code: entry.code, kept: entry.kept, })}\n`); -export function createChannelCaller({ name, call, log = report }) { +export function createChannelCaller({ name, call, log = report, dataRoot = null }) { if (typeof name !== "string" || !NAME.test(name)) throw new PluginError("bad-name", "invalid channel name"); if (typeof call !== "function") throw new TypeError("channel needs a core caller"); - const loader = createLoader({ log }); + const loader = createLoader({ log, dataRoot }); return async (verb, args = {}) => { const settings = await call("plugins", {}); if (!record(settings) || !record(settings.plugins)) { diff --git a/packages/plugins/src/install.mjs b/packages/plugins/src/install.mjs new file mode 100644 index 000000000..ea2c2d1e4 --- /dev/null +++ b/packages/plugins/src/install.mjs @@ -0,0 +1,107 @@ +// Instance-uid, host-only management. No broker verb or organization writer calls this module. +import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { bad, HASH } from './manifest.mjs'; +import { KINDS } from './kinds.mjs'; +import { hashFiles, pluginFiles, pluginPaths, readPlugin, readPluginLock, safeDirectory } from './pinned.mjs'; +const run = (cmd,args,options={}) => { + const r=spawnSync(cmd,args,{encoding:'utf8',maxBuffer:40*1024*1024,timeout:120000,...options}); + if(r.status!==0 || r.error) bad('source-refused'); return r.stdout; +}; +// Validate the complete archive before creating a file. Never extract links, devices, +// duplicate names, absolute paths, dot components, or an archive's permission bits. +const EXTRACT = `import sys,zipfile,tarfile,os,stat,io +kind,src,dst=sys.argv[1:] +archive=zipfile.ZipFile(src) if kind=='zip' else tarfile.open(src) +rows=[]; seen=set(); total=0 +for e in archive.infolist() if kind=='zip' else archive.getmembers(): + n=e.filename if kind=='zip' else e.name + directory=e.is_dir() if kind=='zip' else e.isdir() + n=n.rstrip('/') if directory else n + if not n or n.startswith('/') or any(p in ('','..','.') for p in n.split('/')) or '\\\\' in n: raise ValueError('path') + if n in seen: raise ValueError('duplicate') + seen.add(n) + mode=e.external_attr>>16 if kind=='zip' else e.mode + if kind=='zip': + if stat.S_IFMT(mode) not in (0,stat.S_IFREG,stat.S_IFDIR): raise ValueError('type') + elif not (e.isfile() or e.isdir()): raise ValueError('type') + size=e.file_size if kind=='zip' else e.size + total+=size + if total>33554432 or len(seen)>2048: raise ValueError('size') + rows.append((e,n,directory)) +for e,n,directory in rows: + p=os.path.join(dst,n) + if directory: os.makedirs(p,exist_ok=True) + else: + os.makedirs(os.path.dirname(p),exist_ok=True) + with open(p,'xb') as out: out.write(archive.read(e) if kind=='zip' else archive.extractfile(e).read()) +`; +function sourceFiles(source,commit,scratch) { + if(commit!==undefined) { + if(!/^[a-f0-9]{40}$/.test(commit) || typeof source!=='string' || source.startsWith('-') || /[\x00-\x1f]/.test(source)) bad('commit-required'); + const repo=join(scratch,'repo'),archive=join(scratch,'source.tar'),out=join(scratch,'out'); mkdirSync(out); + run('git',['-c','core.hooksPath=/dev/null','clone','--no-checkout','--no-local','--',source,repo]); + const actual=run('git',['-C',repo,'rev-parse','--verify',`${commit}^{commit}`]).trim(); if(actual!==commit) bad('source-refused'); + run('git',['-C',repo,'archive','--format=tar',`--output=${archive}`,commit]); + run('python3',['-c',EXTRACT,'tar',archive,out]); return pluginFiles(out); + } + if(typeof source!=='string' || /^(https?:|ssh:|git:|git@)/.test(source)) bad('commit-required'); + const st=lstatSync(source); if(st.isSymbolicLink()) bad('unsafe-path'); + if(st.isDirectory()) return pluginFiles(source); + if(!st.isFile() || !source.endsWith('.zip')) bad('source-refused'); + const out=join(scratch,'out');mkdirSync(out);run('python3',['-c',EXTRACT,'zip',source,out]); return pluginFiles(out); +} +function writableRemove(dir) { + if(!existsSync(dir))return; + for(const [p] of pluginFiles(dir)) chmodSync(join(dir,p),0o600); + const dirs=new Set(['']);for(const [p] of pluginFiles(dir)) {let parts=p.split('/');parts.pop();while(parts.length){dirs.add(parts.join('/'));parts.pop();}} + for(const p of dirs)chmodSync(join(dir,p),0o700); + rmSync(dir,{recursive:true}); +} +function withInstallLock(dataRoot,fn) { + safeDirectory(dataRoot);const st=lstatSync(dataRoot);if(st.uid!==process.getuid() || st.mode&0o022)bad("plugin-root");const paths=pluginPaths(dataRoot);let fd; + try {fd=openSync(paths.mutex,'wx',0o600);}catch{bad('plugin-locked');} + try{return fn(paths);}finally{closeSync(fd);unlinkSync(paths.mutex);} +} +function writeLock(lock,doc) { + const temp=`${lock}.new`; try {writeFileSync(temp,JSON.stringify(doc,null,2)+'\n',{flag:'wx',mode:0o600});renameSync(temp,lock);}finally{if(existsSync(temp))unlinkSync(temp);} +} +export function installPlugin({dataRoot,source,commit,expectedHash,print=()=>{}}) { + if(!HASH.test(expectedHash))bad('expected-hash-required'); + const scratch=mkdtempSync(join(tmpdir(),'pl1-')); + try { + const files=sourceFiles(source,commit,scratch),candidate=join(scratch,'candidate');mkdirSync(candidate); + for(const [p,bytes]of files){mkdirSync(join(candidate,p,'..'),{recursive:true});writeFileSync(join(candidate,p),bytes,{flag:'wx',mode:0o600});} + const {manifest}=readPlugin(candidate); + if(hashFiles(files)!==expectedHash || manifest.sha256!==expectedHash)bad('hash-mismatch',manifest.name); + for(const kind of Object.keys(KINDS)) if (existsSync(new URL(`../${kind}/${manifest.name}/index.mjs`,import.meta.url))) bad('core-name'); + // This is before even the instance lock is created. Only validated names are printed. + for(const tool of manifest.tools.filter(t=>t.runs==='host'))print(`host tool: ${tool.name}`); + return withInstallLock(dataRoot,({root,lock})=>{ + const doc=readPluginLock(dataRoot);if(Object.hasOwn(doc.plugins,manifest.name))bad('already-installed',manifest.name); + if(!existsSync(root))mkdirSync(root,{mode:0o700});safeDirectory(root); + const dest=join(root,manifest.name);if(existsSync(dest))bad('already-installed',manifest.name); + const stage=mkdtempSync(join(root,'.install-')); + try { + for(const[p,bytes]of files){mkdirSync(join(stage,p,'..'),{recursive:true,mode:0o755});writeFileSync(join(stage,p),bytes,{flag:'wx',mode:0o444});} + const dirs=new Set(['']);for(const[p]of files){let parts=p.split('/');parts.pop();while(parts.length){dirs.add(parts.join('/'));parts.pop();}} + for(const p of dirs)chmodSync(join(stage,p),0o555); + readPlugin(stage);renameSync(stage,dest); + doc.plugins[manifest.name]={version:manifest.version,source:commit === undefined ? manifest.source : {url:source,commit},sha256:expectedHash}; + try{writeLock(lock,doc);}catch(e){writableRemove(dest);throw e;} + return {name:manifest.name,...doc.plugins[manifest.name]}; + }finally{if(existsSync(stage))writableRemove(stage);} + }); + }catch(e){if(e?.name==='PluginError')throw e;bad('install-refused');}finally{rmSync(scratch,{recursive:true,force:true});} +} +export function removePlugin({dataRoot,name,enabled}) { + if(typeof enabled!=='function')bad('organization-state-required'); + return withInstallLock(dataRoot,({root,lock})=>{ + const doc=readPluginLock(dataRoot);if(!Object.hasOwn(doc.plugins,name))bad('missing-lock'); + if(enabled(name))bad('plugin-enabled',name); + // Remove the lock first: an interrupted removal can never leave unpinned code loadable. + delete doc.plugins[name];writeLock(lock,doc);writableRemove(join(root,name));return {removed:name}; + }); +} diff --git a/packages/plugins/src/loader.mjs b/packages/plugins/src/loader.mjs index c07fa5960..eb83a139e 100644 --- a/packages/plugins/src/loader.mjs +++ b/packages/plugins/src/loader.mjs @@ -1,16 +1,19 @@ // The plug-in loader (REQ-PLUG-1). It switches on the modules the // organization file's `plugins` object names, one key per kind, and loads -// only packages/plugins///index.mjs from the tree this file runs -// from. It takes no path from config and loads nothing outside that tree -// (lead decision 143). A loaded module runs with the host's authority. +// core packages/plugins///index.mjs from its own tree, plus +// hash-pinned installed modules from /plugins only. PL1 +// (#1873), owner-authorized amendment of lead decision 143. No arbitrary +// config path is accepted. A loaded module runs with the host's authority. // -// A refusal keeps the kind's last good modules and is logged. A kind refused +// A core refusal keeps the kind's last good modules and is logged. A refused +// installed plugin is omitted; valid core modules in its kind still load. A kind refused // on its first load has nothing switched on. `loaded()` reports what each // kind actually has switched on. import { lstatSync, readdirSync, realpathSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { KINDS, NAME, PluginError, namesFor } from "./kinds.mjs"; +import { verifiedPlugin, readPluginLock } from "./pinned.mjs"; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -38,7 +41,8 @@ function component(path, directory) { } } -async function loadModule(spec, name, barrier) { +async function loadModule(spec, name, barrier, dataRoot = null) { + if (dataRoot !== null && !hasModule(spec.kind, name)) return loadPinnedModule(spec, name, barrier, dataRoot); const kindDir = join(ROOT, spec.kind); const nameDir = join(kindDir, name); const index = join(nameDir, "index.mjs"); @@ -74,7 +78,10 @@ async function loadModule(spec, name, barrier) { // True when packages/plugins///index.mjs exists in this tree as a real file, with no // symlink on the way down. The setters use it to refuse a name no module answers to before it is // written; the loader's own checks still run at load. -export function hasModule(kind, name) { +export function hasModule(kind, name, dataRoot = null) { + if (dataRoot !== null && !hasModule(kind, name)) { + try { verifiedPlugin(dataRoot, name, kind); return true; } catch { return false; } + } try { if (!Object.hasOwn(KINDS, kind) || !NAME.test(name)) return false; for (const dir of [ROOT, join(ROOT, kind), join(ROOT, kind, name)]) component(dir, true); @@ -113,7 +120,7 @@ export function discoverModules(kind) { // (loadModule, unchanged). One refused module is reported and never hides the others, and nothing // here changes what createLoader has switched on. Node caches an import by path, so a module // whose file changed is read again only after a restart; a removed directory is gone at once. -export async function loadInstalled(kind, { barrier = () => {} } = {}) { +export async function loadInstalled(kind, { barrier = () => {}, dataRoot = null } = {}) { if (!Object.hasOwn(KINDS, kind)) refuse("unknown-kind", `unknown kind ${JSON.stringify(kind)}`); const spec = KINDS[kind]; const modules = [], refused = []; @@ -129,9 +136,14 @@ export async function loadInstalled(kind, { barrier = () => {} } = {}) { return { modules, refused }; } for (const name of found.invalid) report(name, new PluginError("bad-name", `${kind}/${name} doesn't match ${NAME}`, name)); + if (dataRoot !== null) { + try { for (const name of Object.keys(readPluginLock(dataRoot).plugins)) { + try { if (verifiedPlugin(dataRoot, name).manifest.kinds.includes(kind) && !found.names.includes(name)) found.names.push(name); } catch(e) { report(name,e); } + } } catch(e) { report(null,e); } + } for (const name of found.names) { try { - modules.push(await loadModule(spec, name, barrier)); + modules.push(await loadModule(spec, name, barrier, dataRoot)); } catch (e) { report(name, e); } @@ -139,7 +151,7 @@ export async function loadInstalled(kind, { barrier = () => {} } = {}) { return { modules, refused }; } -export function createLoader({ log, barrier = () => {} }) { +export function createLoader({ log, barrier = () => {}, dataRoot = null }) { if (typeof log !== "function") throw new TypeError("createLoader needs a log callback"); const current = new Map(Object.keys(KINDS).map(kind => [kind, Object.freeze([])])); let queue = Promise.resolve(); @@ -151,6 +163,14 @@ export function createLoader({ log, barrier = () => {} }) { event: "plugin-refused", kind, name: e.plugin ?? name, code: e.code, message: e.message, kept: kind === null ? loaded() : current.has(kind) ? current.get(kind).map(m => m.name) : [], }; + if (dataRoot !== null) { + const affected=kind===null ? [...current.keys()] : current.has(kind) ? [kind] : []; + for(const checkedKind of affected)current.set(checkedKind, Object.freeze(current.get(checkedKind).filter(m => { + if (!m.installed) return true; + try { verifiedPlugin(dataRoot,m.name,checkedKind); return true; } catch { return false; } + }))); + entry.kept = kind===null ? loaded() : current.has(kind) ? current.get(kind).map(m=>m.name) : []; + } log(entry); report.push(entry); } @@ -180,9 +200,10 @@ export function createLoader({ log, barrier = () => {} }) { let ok = true; for (const name of names) { try { - next.push(await loadModule(spec, name, barrier)); + next.push(await loadModule(spec, name, barrier, dataRoot)); } catch (e) { refused(kind, name, e, report); + if (dataRoot !== null && !hasModule(kind,name)) continue; ok = false; break; } @@ -219,3 +240,18 @@ export function createLoader({ log, barrier = () => {} }) { // Shared pure plug-in setting classification; no check implementation is loaded. export { isDailyModelQuota } from "./model-quota.mjs"; + +// Verify before every import, including cache hits. Recheck after the test barrier and import. +async function loadPinnedModule(spec, name, barrier, dataRoot) { + let checked = verifiedPlugin(dataRoot,name,spec.kind); + barrier("checked", {kind:spec.kind,name}); + checked = verifiedPlugin(dataRoot,name,spec.kind); + const entry = join(checked.dir,spec.kind,"index.mjs"); + let module; + try { module=await import(pathToFileURL(entry).href + '?pin=' + checked.manifest.sha256); } + catch { refuse("load-failed","installed plugin load refused",name); } + verifiedPlugin(dataRoot,name,spec.kind); + for(const key of spec.exports) if(typeof module[key]!=="function") refuse("missing-export","installed plugin export refused",name); + if(module.interfaceVersion!==spec.interfaceVersion) refuse("interface-version","installed plugin interface refused",name); + return Object.freeze({name,module,installed:true}); +} diff --git a/packages/plugins/src/manifest.mjs b/packages/plugins/src/manifest.mjs new file mode 100644 index 000000000..8e441e2b3 --- /dev/null +++ b/packages/plugins/src/manifest.mjs @@ -0,0 +1,73 @@ +// PL1 (#1873): strict, non-executing manifest validation. All errors are static; +// neither a malformed field nor plugin free text is ever included in a refusal. +import { KINDS, NAME, PluginError } from './kinds.mjs'; +import { buildRegistry } from '../../organization/src/composition.mjs'; + +export const HASH = /^[a-f0-9]{64}$/; +const object = v => v !== null && typeof v === 'object' && !Array.isArray(v); +export const bad = (code = 'invalid-manifest', name = null) => { throw new PluginError(code, `plugin refused: ${code}`, name); }; +const keys = (v, allowed, required = allowed) => { + if (!object(v) || Object.keys(v).some(k => !allowed.includes(k)) || required.some(k => !Object.hasOwn(v, k))) bad(); +}; +const text = (v, max = 2048) => { if (typeof v !== 'string' || !v.length || v.length > max || /[\x00-\x1f]/.test(v)) bad(); }; +export function pluginPath(v) { + if (typeof v !== 'string' || v.length > 200 || !/^[a-zA-Z0-9._/-]+$/.test(v) || v.split('/').some(p => !p || p === '.' || p === '..') || v.startsWith('/')) bad('unsafe-path'); + return v; +} +// Deliberately bounded JSON Schema subset. No remote references, executable validators, +// or unknown keywords. The same schema is enforced before dispatch, not just shown to a model. +export function checkSchema(s, depth = 0) { + keys(s, ['type', 'description', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'minLength', 'maxLength', 'minimum', 'maximum', 'minItems', 'maxItems'], ['type']); + if (depth > 12 || !['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'].includes(s.type)) bad(); + if (s.description !== undefined) text(s.description); + if (s.enum !== undefined && (!Array.isArray(s.enum) || !s.enum.length || s.enum.some(v => v !== null && !['string','number','boolean'].includes(typeof v)))) bad(); + for (const k of ['minLength','maxLength','minItems','maxItems']) if (s[k] !== undefined && (!Number.isSafeInteger(s[k]) || s[k] < 0)) bad(); + for (const k of ['minimum','maximum']) if (s[k] !== undefined && !Number.isFinite(s[k])) bad(); + if (s.type === 'object') { + if (!object(s.properties) || s.additionalProperties !== false || !Array.isArray(s.required) || new Set(s.required).size !== s.required.length || s.required.some(k => typeof k !== 'string' || !Object.hasOwn(s.properties,k))) bad(); + for (const [k,v] of Object.entries(s.properties)) { if (!/^[a-zA-Z][a-zA-Z0-9_]{0,63}$/.test(k)) bad(); checkSchema(v, depth+1); } + } else if (s.properties !== undefined || s.required !== undefined || s.additionalProperties !== undefined) bad(); + if (s.type === 'array') checkSchema(s.items, depth+1); + else if (s.items !== undefined) bad(); + return s; +} +export function validArguments(v, s) { + if (s.enum && !s.enum.some(x => x === v)) return false; + if (s.type === 'object') return object(v) && Object.keys(v).every(k => Object.hasOwn(s.properties,k) && validArguments(v[k],s.properties[k])) && s.required.every(k => Object.hasOwn(v,k)); + if (s.type === 'array') return Array.isArray(v) && v.length >= (s.minItems ?? 0) && v.length <= (s.maxItems ?? 10000) && v.every(x => validArguments(x,s.items)); + if (s.type === 'null') return v === null; + if (s.type === 'integer' ? !Number.isSafeInteger(v) : typeof v !== s.type) return false; + if (typeof v === 'string') return v.length >= (s.minLength ?? 0) && v.length <= (s.maxLength ?? 1000000); + if (typeof v === 'number') return Number.isFinite(v) && v >= (s.minimum ?? -Infinity) && v <= (s.maximum ?? Infinity); + return true; +} +export function declaredSet(m) { + const bare = n => n.slice(m.name.length + 1); + return { name:m.name, roleTemplates:m.roleTemplates.map(t=>({name:bare(t.name),file:t.file})), tools:m.tools.map(t => bare(t.name)), actions:m.actions.map(bare), services:m.services.map(s => ({name:bare(s.name),scopes:s.scopes})) }; +} +export function checkManifest(m) { + keys(m, ['name','version','kinds','interfaceVersions','sha256','source','signature','tools','actions','services','roleTemplates']); + if (typeof m.name !== 'string' || !NAME.test(m.name) || m.name.length > 32) bad(); + if (typeof m.version !== 'string' || !/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(m.version) || !HASH.test(m.sha256)) bad(); + if (!Array.isArray(m.kinds) || !m.kinds.length || new Set(m.kinds).size !== m.kinds.length || m.kinds.some(k => !Object.hasOwn(KINDS,k))) bad('unknown-kind'); + keys(m.interfaceVersions,m.kinds); + for (const k of m.kinds) if (m.interfaceVersions[k] !== KINDS[k].interfaceVersion) bad('interface-version'); + keys(m.source,['url','path','commit','version'],[]); + if (Object.hasOwn(m.source,'url') === Object.hasOwn(m.source,'path') || Object.hasOwn(m.source,'commit') === Object.hasOwn(m.source,'version')) bad(); + for (const v of Object.values(m.source)) text(v); + if (m.source.commit !== undefined && !/^[a-f0-9]{40}$/.test(m.source.commit)) bad(); + if (m.signature !== null) bad(); // reserved, never claims verification + for (const k of ['tools','actions','services','roleTemplates']) if (!Array.isArray(m[k]) || m[k].length > 128) bad(); + const full = n => { if (typeof n !== 'string' || !n.startsWith(`${m.name}.`) || !/^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)+$/.test(n) || n.length > 64) bad(); }; + for (const t of m.tools) { + keys(t,['name','description','arguments','runs','entry']); full(t.name); text(t.description); checkSchema(t.arguments); + if (t.arguments.type !== 'object' || !['sandbox','host'].includes(t.runs)) bad(); pluginPath(t.entry); + if (!t.entry.endsWith('.mjs')) bad(); + } + for (const a of m.actions) full(a); + for (const s of m.services) { keys(s,['name','scopes']); full(s.name); } + const seen = new Set([...m.tools.map(t=>t.name),...m.actions,...m.services.map(s=>s.name)]); + for (const r of m.roleTemplates) { keys(r,['name','file']); full(r.name); pluginPath(r.file); if (seen.has(r.name)) bad(); seen.add(r.name); } + try { buildRegistry({plugins:[declaredSet(m)]}); } catch { bad(); } + return structuredClone(m); +} diff --git a/packages/plugins/src/organization.mjs b/packages/plugins/src/organization.mjs new file mode 100644 index 000000000..1d07b52c7 --- /dev/null +++ b/packages/plugins/src/organization.mjs @@ -0,0 +1,30 @@ +// Non-executing organization view. Disabled/missing capabilities carry only a validated name. +import { buildRegistry } from '../../organization/src/composition.mjs'; +import { declaredSet, bad } from './manifest.mjs'; +import { verifiedPlugin } from './pinned.mjs'; +import { hasModule } from './loader.mjs'; +export {pluginName} from "../../organization/src/vocabulary.mjs"; +import {pluginName} from "../../organization/src/vocabulary.mjs"; +export function organizationPlugins(dataRoot,settings = {}) { + const names=new Set(Object.entries(settings).flatMap(([kind,v])=>(Array.isArray(v)?v:[v]).filter(name=>!hasModule(kind,name)))); + const manifests=[],refused=[]; + if(dataRoot!==null) for(const name of names){try {const m=verifiedPlugin(dataRoot,name).manifest; + if(m.kinds.every(k=>(Array.isArray(settings[k])?settings[k]:[settings[k]]).includes(name)))manifests.push(m); + }catch {refused.push(name);}} + const registry=buildRegistry({plugins:manifests.map(declaredSet)}); + return {registry,manifests,refused,roleTemplates:manifests.flatMap(m=>m.roleTemplates)}; +} +export function assertPluginCapabilities(role,view) { + const check = (cap,registered) => { + const name=pluginName(cap);if(!name)return; + if(!view.manifests.some(m=>m.name===name))bad('plugin-disabled',name); + if(!registered)bad('invalid-request'); + }; + for(const tool of role.tools ?? [])check(tool,view.registry.tools.includes(tool)); + for(const action of [...(role.authority?.withinRole ?? []),...(role.authority?.crossRole ?? [])])check(action,view.registry.actions.includes(action)); + const credentials=Array.isArray(role.credentials) ? role.credentials : Object.keys(role.credentials ?? {}).map(service=>({service})); + for(const credential of credentials) { + const {service,scopes}=credential;check(service,Object.hasOwn(view.registry.services,service)); + if(pluginName(service) && scopes!==undefined && (!Array.isArray(scopes) || scopes.some(s=>!view.registry.services[service].scopes.includes(s))))bad('invalid-request'); + } +} diff --git a/packages/plugins/src/pinned.mjs b/packages/plugins/src/pinned.mjs new file mode 100644 index 000000000..020ba02c7 --- /dev/null +++ b/packages/plugins/src/pinned.mjs @@ -0,0 +1,94 @@ +// PL1: only these derived instance paths may supply installed plugin bytes. +import { createHash } from 'node:crypto'; +import { constants, openSync, closeSync, fstatSync, lstatSync, readdirSync, readFileSync } from 'node:fs'; +import { isAbsolute, join, resolve } from 'node:path'; +import { NAME } from './kinds.mjs'; +import { bad, checkManifest, HASH } from './manifest.mjs'; +export function pluginPaths(dataRoot) { + if (typeof dataRoot !== 'string' || !isAbsolute(dataRoot) || resolve(dataRoot) !== dataRoot) bad('plugin-root'); + return {root:join(dataRoot,'plugins'),lock:join(dataRoot,'plugins.lock.json'),mutex:join(dataRoot,'plugins.install.lock')}; +} +export function safeDirectory(path) { + let current = '/'; + for (const part of resolve(path).split('/').filter(Boolean)) { + current = join(current,part); const st = lstatSync(current); + if (st.isSymbolicLink() || !st.isDirectory()) bad('unsafe-path'); + } +} +function readRegular(path,expected=null) { + let fd;try { + fd=openSync(path,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK); + const st=fstatSync(fd); + if(!st.isFile() || st.nlink!==1 || (expected && (st.ino!==expected.ino || st.dev!==expected.dev)) || st.size>32*1024*1024)bad('unsafe-path'); + return readFileSync(fd); + }finally{if(fd!==undefined)closeSync(fd);} +} +export function pluginFiles(dir) { + safeDirectory(dir); + const files = []; let size = 0; + function walk(rel = '') { + for (const n of readdirSync(join(dir,rel)).sort()) { + if (!/^[a-zA-Z0-9._-]+$/.test(n)) bad('unsafe-path'); + const path = rel ? `${rel}/${n}` : n, st = lstatSync(join(dir,path)); + if (st.uid!==process.getuid() || st.mode&0o022 || st.isSymbolicLink() || (!st.isDirectory() && !st.isFile()) || st.nlink > (st.isDirectory() ? Infinity : 1)) bad('unsafe-path'); + if (st.isDirectory()) walk(path); + else { size += st.size; if (size > 32*1024*1024 || files.length > 2048) bad('plugin-too-large'); files.push([path,readRegular(join(dir,path),st)]); } + } + } + walk(); return files.sort(([a],[b]) => a < b ? -1 : a > b ? 1 : 0); +} +// Hash every byte, replacing only the top-level sha256 string with 64 zeroes to +// break the manifest's self-reference. Formatting and all other metadata stay pinned. +function manifestBytes(bytes) { + let text,value;try{text=new TextDecoder('utf-8',{fatal:true,ignoreBOM:true}).decode(bytes);value=JSON.parse(text);}catch{bad();} + if(!value || typeof value!=="object" || !HASH.test(value.sha256))bad(); + let depth=0,found=0,result=text; + for(let i=0;i hashFiles(pluginFiles(dir)); +export function readPlugin(dir) { + const files=pluginFiles(dir); let manifest; + try { manifest=checkManifest(JSON.parse(files.find(([p])=>p==='plugin.json')?.[1])); } catch { bad('invalid-manifest'); } + if (hashFiles(files)!==manifest.sha256) bad('hash-mismatch',manifest.name); + const names=new Set(files.map(([p])=>p)); + for (const p of [...manifest.kinds.map(k=>`${k}/index.mjs`),...manifest.tools.map(t=>t.entry),...manifest.roleTemplates.map(t=>t.file)]) if (!names.has(p)) bad('missing-file',manifest.name); + return {manifest,files}; +} +export function readPluginLock(dataRoot) { + const {lock}=pluginPaths(dataRoot); safeDirectory(dataRoot); + const rootStat=lstatSync(dataRoot);if(rootStat.uid!==process.getuid() || rootStat.mode&0o022)bad('plugin-root'); + let doc; + try { const st=lstatSync(lock); if (st.isSymbolicLink() || !st.isFile() || st.uid!==process.getuid() || st.mode&0o022) bad('invalid-lock'); doc=JSON.parse(readRegular(lock,st)); } + catch (e) { if(e.code==='ENOENT') return {version:1,plugins:{}}; bad('invalid-lock'); } + if (!doc || doc.version!==1 || Object.keys(doc).some(k=>!['version','plugins'].includes(k)) || !doc.plugins || Array.isArray(doc.plugins) || typeof doc.plugins!=='object') bad('invalid-lock'); + for(const [n,p] of Object.entries(doc.plugins)) if(!NAME.test(n) || !p || Object.keys(p).sort().join(',')!=='sha256,source,version' || !HASH.test(p.sha256) || typeof p.version!=='string') bad('invalid-lock'); + return doc; +} +export function verifiedPlugin(dataRoot,name,kind=null) { + if(typeof name!=='string' || !NAME.test(name)) bad('bad-name'); + try { + const pin=readPluginLock(dataRoot).plugins[name]; if(!pin) bad('missing-lock',name); + const {root}=pluginPaths(dataRoot); const dir=join(root,name); const result=readPlugin(dir); + if(result.manifest.name!==name || result.manifest.sha256!==pin.sha256 || result.manifest.version!==pin.version) bad('hash-mismatch',name); + if(kind && !result.manifest.kinds.includes(kind)) bad('unknown-kind',name); + return {...result,dir}; + } catch(e) { bad(['missing-lock','hash-mismatch','unknown-kind','unsafe-path','invalid-lock'].includes(e.code)?e.code:'plugin-unavailable',name); } +} diff --git a/packages/plugins/src/system-types.mjs b/packages/plugins/src/system-types.mjs index 62ba27ff9..107474092 100644 --- a/packages/plugins/src/system-types.mjs +++ b/packages/plugins/src/system-types.mjs @@ -2,8 +2,8 @@ import { loadInstalled } from './loader.mjs'; import { NAME } from './kinds.mjs'; // Discovery is fail-closed: malformed or unloadable installed modules cannot be advertised. -export async function installedSystemTypes() { - const { modules, refused } = await loadInstalled('system-type'); +export async function installedSystemTypes({dataRoot = null} = {}) { + const { modules, refused } = await loadInstalled('system-type',{dataRoot}); if (refused.length) throw Object.assign(new Error('system type module refused'), { code: 'system-type-invalid' }); return modules.map(({ name, module }) => { const value = module.systemType(); diff --git a/packages/plugins/src/theme.mjs b/packages/plugins/src/theme.mjs index d713d5077..526e00bc5 100644 --- a/packages/plugins/src/theme.mjs +++ b/packages/plugins/src/theme.mjs @@ -111,8 +111,8 @@ export function checkTheme(value, name = null) { // Every installed theme module that loads and checks, as [{ id, displayName, tokens }] sorted by // id, and what was refused. Each refusal goes to `log` as the loader's plugin-refused entry. // Nothing names the themes: loadInstalled lists packages/plugins/theme/. -export async function installedThemes({ log = () => {}, barrier } = {}) { - const { modules, refused } = await loadInstalled("theme", { barrier }); +export async function installedThemes({ log = () => {}, barrier, dataRoot = null } = {}) { + const { modules, refused } = await loadInstalled("theme", { barrier, dataRoot }); const themes = [], problems = [...refused]; for (const { name, module } of modules) { try { diff --git a/packages/plugins/src/tools.mjs b/packages/plugins/src/tools.mjs new file mode 100644 index 000000000..3183a62da --- /dev/null +++ b/packages/plugins/src/tools.mjs @@ -0,0 +1,59 @@ +// Trusted host dispatch for declared tools. No plugin code is imported until the +// current organization setting and the complete installed package pass verification. +import { spawn } from 'node:child_process'; +import { existsSync, realpathSync } from 'node:fs'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { organizationPlugins, assertPluginCapabilities, pluginName } from './organization.mjs'; +import { verifiedPlugin } from './pinned.mjs'; +import { bad, validArguments } from './manifest.mjs'; +const SCRIPT = `let input=''; for await(const b of process.stdin)input+=b; +const {entry,args}=JSON.parse(input); const m=await import(entry); +process.stdout.write(JSON.stringify(await m.execute(args)));`; +function sandboxTool(dir,entry,args) { + // A fresh mount/network/user namespace with only runtime libraries and the pinned package. + // No home, config, workspace, sockets or inherited environment is exposed. + const binds=['/usr','/lib','/lib64','/bin'].filter(existsSync).flatMap(p=>['--ro-bind',realpathSync(p),p]); + return new Promise((resolve,reject)=>{ + const child=spawn('bwrap',['--die-with-parent','--unshare-all','--new-session','--proc','/proc','--dev','/dev','--tmpfs','/tmp',...binds,'--ro-bind',realpathSync(process.execPath),'/node','--ro-bind',dir,'/plugin','--chdir','/plugin','--clearenv','/node','--input-type=module','-e',SCRIPT],{env:{PATH:'/usr/bin:/bin'},stdio:['pipe','pipe','pipe']}); + let out='',size=0;const timer=setTimeout(()=>child.kill('SIGKILL'),30000); + child.stdout.on('data',b=>{size+=b.length;if(size>1024*1024)child.kill('SIGKILL');else out+=b;}); + child.stderr.resume();child.on('error',()=>{clearTimeout(timer);reject(new Error('sandbox refused'));}); + child.stdin.on('error',()=>{});child.stdin.end(JSON.stringify({entry:`file:///plugin/${entry}`,args})); + child.on('close',code=>{clearTimeout(timer);try{if(code!==0 || size>1024*1024)throw Error();resolve(JSON.parse(out));}catch{reject(new Error('sandbox refused'));}}); + }); +} +export function pluginService({dataRoot,read}) { + function check(business,instance) { + const b=read(business);const entry=b.roles?.[instance];let role=b.definitions?.[entry?.definition] ?? entry; + if(!role)bad('plugin-disabled'); + role=structuredClone(role); + for(const vars of [b.vars,entry?.vars]) { + if(Array.isArray(vars?.['limits.tools']))role.tools=(role.tools ?? []).filter(t=>vars['limits.tools'].includes(t)); + if(Array.isArray(vars?.['limits.authority']) && role.authority)for(const key of ['withinRole','crossRole'])role.authority[key]=(role.authority[key] ?? []).filter(a=>vars['limits.authority'].includes(a)); + } + const view=organizationPlugins(dataRoot,b.plugins);assertPluginCapabilities(role,view);return {role,view}; + } + async function call(business,instance,name,args) { + const prefix=pluginName(name);if(!prefix)bad('invalid-request'); + const {role,view}=check(business,instance); + if(!role.tools?.includes(name))bad('not-authorized'); + const tool=view.manifests.flatMap(m=>m.tools).find(t=>t.name===name);if(!tool)bad('plugin-disabled',prefix); + if(!validArguments(args,tool.arguments))bad('invalid-request'); + const {dir,manifest}=verifiedPlugin(dataRoot,prefix); + if(tool.runs==='sandbox') { + try {return await sandboxTool(dir,tool.entry,structuredClone(args));} + catch {bad('plugin-tool-failed',prefix);} + } + let module; + try {module=await import(pathToFileURL(join(dir,tool.entry)).href+`?pin=${manifest.sha256}`);} + catch {bad('plugin-tool-failed',prefix);} + // A concurrent disable while import was in flight must refuse before execute. + check(business,instance);verifiedPlugin(dataRoot,prefix); + if(typeof module.execute!=='function')bad('missing-export'); + // Plugin code cannot impersonate a trusted refusal by throwing a matching error name. + try {return await module.execute(structuredClone(args));} + catch {bad('plugin-tool-failed',prefix);} + } + return {check,call}; +} diff --git a/packages/plugins/tests/helpers.mjs b/packages/plugins/tests/helpers.mjs index 79467900c..8591b7fb3 100644 --- a/packages/plugins/tests/helpers.mjs +++ b/packages/plugins/tests/helpers.mjs @@ -27,6 +27,9 @@ export function tree(t) { t.after(() => rmSync(root, { recursive: true, force: true })); const pkg = join(root, "packages", "plugins"); cpSync(SRC, join(pkg, "src"), { recursive: true }); + const org = join(root,"packages","organization","src"); + mkdirSync(org,{recursive:true}); + for (const file of ["composition.mjs","vocabulary.mjs","errors.mjs","util.mjs"]) cpSync(join(SRC,"..","..","organization","src",file),join(org,file)); const write = (kind, name, body) => { const dir = join(pkg, kind, name); mkdirSync(dir, { recursive: true }); diff --git a/packages/plugins/tests/loader.test.mjs b/packages/plugins/tests/loader.test.mjs index 85d8324be..60d71d861 100644 --- a/packages/plugins/tests/loader.test.mjs +++ b/packages/plugins/tests/loader.test.mjs @@ -105,6 +105,7 @@ test("REQ-PLUG-1 a symlinked packages/plugins refuses (run with --preserve-symli t.after(() => rmSync(other, { recursive: true, force: true })); mkdirSync(join(other, "packages")); symlinkSync(s.pkg, join(other, "packages", "plugins")); + symlinkSync(join(s.root,"packages","organization"), join(other,"packages","organization")); const url = new URL(`file://${join(other, "packages", "plugins", "src", "loader.mjs")}`).href; const script = `const { createLoader } = await import(${JSON.stringify(url)}); const logged = []; const loader = createLoader({ log: e => logged.push(e) }); diff --git a/packages/plugins/tests/pinned.test.mjs b/packages/plugins/tests/pinned.test.mjs new file mode 100644 index 000000000..f4c5c547a --- /dev/null +++ b/packages/plugins/tests/pinned.test.mjs @@ -0,0 +1,213 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { checkManifest } from '../src/manifest.mjs'; +import { pluginHash, readPluginLock, verifiedPlugin } from '../src/pinned.mjs'; +import { installPlugin, removePlugin } from '../src/install.mjs'; +import { createLoader } from '../src/loader.mjs'; +import { organizationPlugins, assertPluginCapabilities } from '../src/organization.mjs'; +import { pluginService } from '../src/tools.mjs'; +import { typedTools } from '../../harness/src/tools.mjs'; +import { resolveInstance } from '../../organization/src/resolve.mjs'; +import { pluginInstallCommand } from '../../cli/src/plugin-install.mjs'; +import { pluginSettings } from '../../organization/src/plugin-settings.mjs'; +import { hasModule } from '../src/loader.mjs'; +import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs'; +const SECRET='sk-test-NOT-A-SECRET-1234'; +function fixture(t) { + const root=mkdtempSync(join(tmpdir(),'pl1-')),src=join(root,'src'),dataRoot=join(root,'data'); + mkdirSync(src);mkdirSync(dataRoot);mkdirSync(join(src,'usage-source'));mkdirSync(join(src,'tools')); + t.after(()=>{const thaw=p=>{const st=lstatSync(p);if(st.isSymbolicLink())return;if(st.isDirectory()){chmodSync(p,0o700);for(const n of readdirSync(p))thaw(join(p,n));}};thaw(root);rmSync(root,{recursive:true,force:true});}); + writeFileSync(join(src,'usage-source','index.mjs'),'export const interfaceVersion=1; export const readTurn=()=>"fixture";'); + writeFileSync(join(src,'tools','echo.mjs'),`export const execute=(args)=>({answer:args.text}); // ${SECRET}`); + const manifest={name:'acmeplug',version:'1.0.0',kinds:['usage-source'],interfaceVersions:{'usage-source':1},sha256:'0'.repeat(64),source:{path:'fixture',version:'1.0.0'},signature:null,tools:[{name:'acmeplug.echo',description:SECRET,arguments:{type:'object',properties:{text:{type:'string'}},required:['text'],additionalProperties:false},runs:'host',entry:'tools/echo.mjs'}],actions:['acmeplug.publish'],services:[{name:'acmeplug.account',scopes:['read']}],roleTemplates:[{name:'acmeplug.writer',file:'writer.json'}]}; + writeFileSync(join(src,'writer.json'),'{}'); + const seal=()=>{writeFileSync(join(src,'plugin.json'),JSON.stringify(manifest));manifest.sha256=pluginHash(src);writeFileSync(join(src,'plugin.json'),JSON.stringify(manifest));return manifest.sha256;}; + const hash=seal();const install=(source=src,extra={})=>installPlugin({dataRoot,source,expectedHash:hash,...extra}); + return {root,src,dataRoot,manifest,seal,hash,install}; +} +const refused=code=>e=>e.code===code && !e.message.includes(SECRET); +test('PL1 strict fields, kinds, core names, prefixes, hard waits and traversal refuse without echo',t=>{ + const f=fixture(t); + for(const mutate of [m=>m.extra=SECRET,m=>m.tools[0].extra=SECRET,m=>m.kinds=['unknown'],m=>m.tools[0].name='read',m=>m.tools[0].name='acmeplug.read',m=>m.actions=['acmeplug.deploy'],m=>m.actions=['acmeplug.git.push.remote'],m=>m.roleTemplates[0].file='../file',m=>m.tools[0].arguments.$ref=SECRET,m=>m.name='git']){ + const m=structuredClone(f.manifest);mutate(m);assert.throws(()=>checkManifest(m),e=>!e.message.includes(SECRET)); + } +}); +test('PL1 directory install is pinned and read-only; host tools printed before any instance write',t=>{ + const f=fixture(t),printed=[];const entry=f.install(f.src,{print:line=>{assert.deepEqual(readPluginLock(f.dataRoot).plugins,{});assert.equal(existsSync(join(f.dataRoot,'plugins')),false);printed.push(line);}}); + assert.equal(entry.sha256,f.hash);assert.deepEqual(printed,['host tool: acmeplug.echo']); + for(const p of ['plugins/acmeplug','plugins/acmeplug/tools'])assert.equal(lstatSync(join(f.dataRoot,p)).mode&0o777,0o555); + assert.equal(lstatSync(join(f.dataRoot,'plugins/acmeplug/plugin.json')).mode&0o777,0o444); + assert.equal(verifiedPlugin(f.dataRoot,'acmeplug').manifest.sha256,f.hash); +}); +test('PL1 mismatch and symlink sources refuse before instance writes',t=>{ + const f=fixture(t);assert.throws(()=>f.install(f.src,{expectedHash:'f'.repeat(64)}),refused('hash-mismatch')); + symlinkSync('/etc/passwd',join(f.src,'escape'));assert.throws(()=>f.install(),refused('unsafe-path')); + assert.equal(existsSync(join(f.dataRoot,'plugins')),false); +}); +test('PL1 zip traversal, symlinks, and git URL without a full commit refuse',t=>{ + const f=fixture(t);const zip=join(f.root,'bad.zip'); + for(const script of ["z.writestr('../escape','bad')","e=zipfile.ZipInfo('link');e.create_system=3;e.external_attr=(0o120777<<16);z.writestr(e,'/etc/passwd')"]){ + assert.equal(spawnSync('python3',['-c',`import zipfile\nz=zipfile.ZipFile(${JSON.stringify(zip)},'w')\n${script}\nz.close()`]).status,0); + assert.throws(()=>f.install(zip),refused('source-refused')); + } + assert.throws(()=>f.install('https://example.invalid/repo.git'),refused('commit-required')); + assert.throws(()=>f.install(f.src,{commit:'abc'}),refused('commit-required')); +}); +test('PL1 directory, generated zip and local bare git at full commit land the same hash',t=>{ + const f=fixture(t);const zip=join(f.root,'source.zip'); + assert.equal(spawnSync('python3',['-c',`import zipfile,os\nz=zipfile.ZipFile(${JSON.stringify(zip)},'w')\nfor root,dirs,files in os.walk(${JSON.stringify(f.src)}):\n for n in files:z.write(os.path.join(root,n),os.path.relpath(os.path.join(root,n),${JSON.stringify(f.src)}))\nz.close()`]).status,0); + const git=(args)=>{const r=spawnSync('git',args,{encoding:'utf8'});assert.equal(r.status,0,r.stderr);return r.stdout.trim();}; + git(['init',f.src]);git(['-C',f.src,'add','.']);git(['-C',f.src,'-c','user.name=Fixture','-c','user.email=fixture@example.invalid','commit','-m','fixture']); + const commit=git(['-C',f.src,'rev-parse','HEAD']),bare=join(f.root,'bare.git');git(['clone','--bare',f.src,bare]); + // The local directory used for install intentionally has no .git administrative files. + rmSync(join(f.src,'.git'),{recursive:true}); + for(const [source,opts]of [[f.src,{}],[zip,{}],[bare,{commit}]]){ + const d=mkdtempSync(join(f.root,'instance-'));const r=installPlugin({dataRoot:d,source,expectedHash:f.hash,...opts});assert.equal(r.sha256,f.hash);assert.equal(readPluginLock(d).plugins.acmeplug.sha256,f.hash); + } +}); +test('PL1 changed file/missing lock refuses next load and evicts installed code, core keeps running',async t=>{ + const f=fixture(t);f.install();const logs=[],loader=createLoader({dataRoot:f.dataRoot,log:e=>logs.push(e)}); + const settings={'usage-source':['transcript','acmeplug'],harness:['pi']};assert.equal((await loader.load(settings)).refused.length,0); + const file=join(f.dataRoot,'plugins/acmeplug/tools/echo.mjs');chmodSync(file,0o600);writeFileSync(file,SECRET); + const result=await loader.load(settings);assert.equal(result.refused[0].code,'hash-mismatch');assert.deepEqual(loader.loaded()['usage-source'],['transcript']);assert.deepEqual(loader.loaded().harness,['pi']); + assert.ok(!JSON.stringify(logs).includes(SECRET)); + rmSync(join(f.dataRoot,'plugins.lock.json'));assert.equal((await loader.load(settings)).refused[0].code,'missing-lock'); +}); +test('PL1 registry includes only enabled verified declarations, services and templates',t=>{ + const f=fixture(t);f.install();const view=organizationPlugins(f.dataRoot,{'usage-source':['acmeplug']}); + assert.ok(view.registry.tools.includes('acmeplug.echo'));assert.ok(view.registry.routed.includes('acmeplug.publish'));assert.deepEqual(view.registry.services['acmeplug.account'].scopes,['read']);assert.equal(view.roleTemplates[0].name,'acmeplug.writer'); + for(const settings of [{},{'usage-source':['missing']}])assert.throws(()=>assertPluginCapabilities({tools:['acmeplug.echo']},organizationPlugins(f.dataRoot,settings)),e=>e.code==='plugin-disabled' && e.plugin==='acmeplug' && !e.message.includes(SECRET)); +}); +test('PL1 disabled or missing tools/actions refuse at launch with name only, enabled tool is bundled',t=>{ + const f=fixture(t);f.install();const definition={name:'writer',contractPath:'/contract',tools:['acmeplug.echo'],network:'none',authority:{withinRole:['acmeplug.publish'],crossRole:[]},credentials:[],messaging:null,skills:[]}; + const business={id:'acme',roles:{writer:{definition:'writer',vars:{},credentials:{}}},definitions:{writer:definition},vars:{},arbiters:{}}; + const args={system:{dataRoot:f.dataRoot},business,instance:'writer',enforcePlugins:true}; + assert.throws(()=>resolveInstance(args),e=>e.code==='plugin-disabled' && e.plugin==='acmeplug'); + business.plugins={'usage-source':['acmeplug']};const r=resolveInstance(args);assert.equal(typedTools({resolved:r}).find(t=>t.name==='acmeplug.echo').pluginTool,'acmeplug.echo'); + business.plugins={};assert.throws(()=>resolveInstance(args),refused('plugin-disabled')); +}); +test('PL1 running tool calls recheck enabled setting, pin, arguments and held tool',async t=>{ + const f=fixture(t);f.install();const role={tools:['acmeplug.echo'],authority:{withinRole:[],crossRole:[]}};let enabled=true; + const service=pluginService({dataRoot:f.dataRoot,read:()=>({plugins:enabled?{'usage-source':['acmeplug']}:{},roles:{writer:role}})}); + assert.deepEqual(await service.call('acme','writer','acmeplug.echo',{text:'hello'}),{answer:'hello'}); + await assert.rejects(service.call('acme','writer','acmeplug.echo',{unexpected:SECRET}),refused('invalid-request')); + enabled=false;await assert.rejects(service.call('acme','writer','acmeplug.echo',{text:SECRET}),e=>e.code==='plugin-disabled' && e.plugin==='acmeplug' && !e.message.includes(SECRET)); +}); +test('PL1 remove refuses enabled organizations; host CLI rejects organization/tester options and agents',async t=>{ + const f=fixture(t);f.install();assert.throws(()=>removePlugin({dataRoot:f.dataRoot,name:'acmeplug',enabled:()=>true}),refused('plugin-enabled')); + const dir=join(f.root,'config');mkdirSync(dir,{mode:0o700});let out='';const io={env:{},stdout:{write:v=>out+=v}}; + for(const args of [['install',f.src,'--sha256',f.hash,'--org','acme'],['install',f.src,'--sha256',f.hash,'--tester']])await assert.rejects(pluginInstallCommand(args,io,{system:{dataRoot:f.dataRoot},dir})); + await assert.rejects(pluginInstallCommand(['list'],{...io,env:{MOSAIC_RUN_ID:'fixture'}},{system:{dataRoot:f.dataRoot},dir})); + writeJson(join(dir,'businesses','acme.json'),{plugins:{'usage-source':['acmeplug']}}); + await assert.rejects(pluginInstallCommand(['remove','acmeplug'],io,{system:{dataRoot:f.dataRoot},dir}),e=>e.message.includes('plugin-enabled')); +}); +test('PL1 enabling a tampered installed plugin refuses before organization write',t=>{ + const f=fixture(t);f.install();const doc=businessDoc(f.root),rolesDir=rolesCopy(f.root);writeJson(join(f.root,'businesses','acme.json'),doc); + const settings=pluginSettings({dir:f.root,rolesDir,dataRoot:f.dataRoot,exists:(k,n)=>hasModule(k,n,f.dataRoot)}); + const before=settings.read('acme');const file=join(f.dataRoot,'plugins/acmeplug/tools/echo.mjs');chmodSync(file,0o600);writeFileSync(file,SECRET); + assert.throws(()=>settings.set('acme',{revision:before.revision,plugins:{'usage-source':['acmeplug']}},'jason'),refused('invalid-plugins')); + assert.deepEqual(settings.read('acme'),before); +}); + +test('PL1 sandbox tools execute with no home or network namespace access',async t=>{ + const f=fixture(t);f.manifest.tools[0].runs='sandbox'; + writeFileSync(join(f.src,'tools/echo.mjs'),`import {existsSync} from 'node:fs';export const execute=()=>({home:existsSync('/home'),config:existsSync('/mnt/storage')});`); + const hash=f.seal();f.install(f.src,{expectedHash:hash}); + const service=pluginService({dataRoot:f.dataRoot,read:()=>({plugins:{'usage-source':['acmeplug']},roles:{writer:{tools:['acmeplug.echo'],authority:{withinRole:[],crossRole:[]}}}})}); + assert.deepEqual(await service.call('acme','writer','acmeplug.echo',{text:'hello'}),{home:false,config:false}); +}); + +test('PL1 broker launch and running dispatch enforce enable state; organization install and member/agent enable refuse',async t=>{ + const {Store}=await import('../../bus/src/store.mjs');const {Broker}=await import('../../bus/src/broker.mjs'); + const f=fixture(t);f.install();const authority={withinRole:[],crossRole:[]}; + const business={id:'acme',human:'owner',plugins:{'usage-source':['acmeplug']},arbiters:{technical:'chief',delivery:'chief'},roles:{chief:{authority},writer:{tools:['acmeplug.echo'],authority}}}; + const service=pluginService({dataRoot:f.dataRoot,read:()=>business});const store=new Store(f.dataRoot);t.after(()=>store.close()); + const b=new Broker({store,businesses:{acme:business},pluginService:service}); + const cap=b.bindLaunch({business:'acme',role:'writer',run:'writer-1',harness:'pi'});b.request(cap,{verb:'role.claim'}); + assert.deepEqual(await b.request(cap,{verb:'plugin.tool',args:{tool:'acmeplug.echo',arguments:{text:'working'}}}),{answer:'working'}); + const member=b.bindReader({business:'acme'}); + for(const c of [cap,member])assert.throws(()=>b.request(c,{verb:'plugins.set',args:{revision:'a'.repeat(64),plugins:{'usage-source':['acmeplug']}}}),/human-required/); + assert.throws(()=>b.request(cap,{verb:'plugins.install',args:{source:SECRET}}),e=>!e.message.includes(SECRET)); + business.plugins={}; + await assert.rejects(b.request(cap,{verb:'plugin.tool',args:{tool:'acmeplug.echo',arguments:{text:SECRET}}}),e=>e.code==='plugin-disabled' && e.reason==='acmeplug' && !e.message.includes(SECRET)); + assert.throws(()=>b.bindLaunch({business:'acme',role:'writer',run:'writer-2',harness:'pi'}),e=>e.code==='plugin-disabled' && e.reason==='acmeplug'); + assert.equal(b.bindLaunch({business:'acme',role:'chief',run:'chief-1',harness:'pi'}).length,64,'core can still launch'); +}); + +test('PL1 registry preserves capability categories and credential scope ceilings',t=>{ + const f=fixture(t);f.install();const view=organizationPlugins(f.dataRoot,{'usage-source':['acmeplug']}); + for(const role of [{tools:['acmeplug.publish']},{authority:{withinRole:['acmeplug.echo']}},{credentials:[{service:'acmeplug.account',scopes:['admin']}]}])assert.throws(()=>assertPluginCapabilities(role,view),refused('invalid-request')); +}); + +test('PL1 manifest whitespace is pinned; remove succeeds after the owner disables it',t=>{ + const f=fixture(t);f.install();const file=join(f.dataRoot,'plugins/acmeplug/plugin.json');chmodSync(file,0o600);writeFileSync(file,readFileSync(file,'utf8')+'\n'); + assert.throws(()=>verifiedPlugin(f.dataRoot,'acmeplug'),refused('hash-mismatch')); + removePlugin({dataRoot:f.dataRoot,name:'acmeplug',enabled:()=>false}); + assert.deepEqual(readPluginLock(f.dataRoot).plugins,{});assert.equal(existsSync(join(f.dataRoot,'plugins/acmeplug')),false); +}); + +test('PL1 CLI host install/list/remove never calls a bus transport',async t=>{ + const {main}=await import('../../cli/src/cli.mjs');const f=fixture(t),dir=join(f.root,'config');mkdirSync(dir,{mode:0o700}); + let output='';const io={env:{},stdout:{write:v=>output+=v},stderr:{write:()=>{}}};const deps={system:{dataRoot:f.dataRoot},accountsConfigDir:dir,transport:()=>{throw Error('bus must not be used');}}; + await main(['plugins','install',f.src,'--sha256',f.hash],io,deps);assert.match(output,/host tool: acmeplug.echo/);assert.ok(!output.includes(SECRET)); + output='';await main(['plugins','list'],io,deps);assert.equal(JSON.parse(output).plugins.acmeplug.sha256,f.hash); + output='';await main(['plugins','remove','acmeplug'],io,deps);assert.deepEqual(JSON.parse(output),{removed:'acmeplug'}); +}); + +test('PL1 a running plugin tool obeys a newly narrowed tool ceiling',async t=>{ + const f=fixture(t);f.install();const b={plugins:{'usage-source':['acmeplug']},roles:{writer:{tools:['acmeplug.echo'],authority:{withinRole:[],crossRole:[]},vars:{}}}}; + const service=pluginService({dataRoot:f.dataRoot,read:()=>b});assert.deepEqual(await service.call('acme','writer','acmeplug.echo',{text:'before'}),{answer:'before'}); + b.roles.writer.vars['limits.tools']=['read'];await assert.rejects(service.call('acme','writer','acmeplug.echo',{text:'after'}),refused('not-authorized')); +}); + +test('PL1 an installed package cannot reuse a core module name from another kind',t=>{ + const f=fixture(t);f.manifest.name='pi';for(const tool of f.manifest.tools)tool.name=tool.name.replace('acmeplug.','pi.');f.manifest.actions=['pi.publish'];f.manifest.services[0].name='pi.account';f.manifest.roleTemplates[0].name='pi.writer'; + const hash=f.seal();assert.throws(()=>f.install(f.src,{expectedHash:hash}),refused('core-name')); + assert.equal(existsSync(join(f.dataRoot,'plugins')),false); +}); + +test('PL1 malformed settings cannot retain an installed module whose pin is now bad',async t=>{ + const f=fixture(t);f.install();const loader=createLoader({dataRoot:f.dataRoot,log:()=>{}});await loader.load({'usage-source':['transcript','acmeplug'],harness:['pi']}); + const file=join(f.dataRoot,'plugins/acmeplug/tools/echo.mjs');chmodSync(file,0o600);writeFileSync(file,SECRET); + await loader.load(null);assert.deepEqual(loader.loaded()['usage-source'],['transcript']);assert.deepEqual(loader.loaded().harness,['pi']); +}); + +test('PL1 runtime discovers installed harness levels from the instance root',async t=>{ + const {harnessLevels}=await import('../../bus/src/runtime.mjs'); + const {KINDS}=await import('../src/kinds.mjs'); + const f=fixture(t);f.manifest.kinds=['harness'];f.manifest.interfaceVersions={harness:1}; + mkdirSync(join(f.src,'harness')); + writeFileSync(join(f.src,'harness/index.mjs'),KINDS.harness.exports.map(name=>`export function ${name}(){return ${name==='levels'?"['high']":'null'};}`).join('\n')+'\nexport const interfaceVersion=1;'); + const hash=f.seal();f.install(f.src,{expectedHash:hash}); + const levels=await harnessLevels({dataRoot:f.dataRoot,log:()=>{}}); + assert.deepEqual(levels('acmeplug'),['high']);assert.ok(levels.names.includes('pi')); +}); + +test('PL1 invalid UTF-8 manifest bytes refuse instead of normalizing outside the pin',t=>{ + const f=fixture(t),path=join(f.src,'plugin.json'),bytes=readFileSync(path); + bytes[bytes.indexOf(SECRET)]=0xff;writeFileSync(path,bytes); + assert.throws(()=>pluginHash(f.src),refused('invalid-manifest')); +}); + +test('PL1 tool exceptions cannot impersonate trusted refusals or echo payload bytes',async t=>{ + const f=fixture(t); + writeFileSync(join(f.src,'tools/echo.mjs'),`export function execute(){throw Object.assign(new Error(${JSON.stringify(SECRET)}),{name:'PluginError',code:'plugin-disabled',plugin:${JSON.stringify(SECRET)}});}`); + const hash=f.seal();f.install(f.src,{expectedHash:hash}); + const service=pluginService({dataRoot:f.dataRoot,read:()=>({plugins:{'usage-source':['acmeplug']},roles:{writer:{tools:['acmeplug.echo'],authority:{withinRole:[],crossRole:[]}}}})}); + await assert.rejects(service.call('acme','writer','acmeplug.echo',{text:'hello'}),e=>e.code==='plugin-tool-failed' && e.plugin==='acmeplug' && !e.message.includes(SECRET)); +}); + +test('PL1 A3 organization copy readers preserve the derived installed-plugin root',async t=>{ + const {withCopies,loadWithCopies}=await import('../../organization/src/agent-copies.mjs'); + const f=fixture(t);f.manifest.kinds=['system-type'];f.manifest.interfaceVersions={'system-type':1}; + mkdirSync(join(f.src,'system-type'));writeFileSync(join(f.src,'system-type/index.mjs'),'export const interfaceVersion=1;export function systemType(){return {}};'); + const hash=f.seal();f.install(f.src,{expectedHash:hash}); + const doc=businessDoc(f.root),rolesDir=rolesCopy(f.root);doc.plugins={...doc.plugins,'system-type':['acmeplug']};writeJson(join(f.root,'businesses/acme.json'),doc); + const options={rolesDir,dataRoot:f.dataRoot,snapshot:null}; + assert.deepEqual(withCopies(doc,join(f.root,'businesses/acme.json'),options).plugins['system-type'],['acmeplug']); + assert.deepEqual(loadWithCopies('acme',{...options,dir:f.root}).plugins['system-type'],['acmeplug']); +}); -- 2.54.0 From 99ce579e3c25cef13df69af736084bd07b1cb5a0 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 10 Nov 2026 23:40:26 -0600 Subject: [PATCH 2/2] fix(plugins): keep imports on the verified package pin (#1873) --- packages/bus/src/refusals.mjs | 3 +- .../organization/src/organization-config.mjs | 8 +- packages/plugins/README.md | 13 +++ packages/plugins/src/imports.mjs | 35 ++++++++ packages/plugins/src/loader.mjs | 3 +- packages/plugins/src/pinned.mjs | 19 ++++- packages/plugins/src/tools.mjs | 10 ++- packages/plugins/tests/pinned.test.mjs | 85 +++++++++++++++++++ 8 files changed, 166 insertions(+), 10 deletions(-) create mode 100644 packages/plugins/src/imports.mjs diff --git a/packages/bus/src/refusals.mjs b/packages/bus/src/refusals.mjs index 285a3c726..b20f056f2 100644 --- a/packages/bus/src/refusals.mjs +++ b/packages/bus/src/refusals.mjs @@ -8,7 +8,8 @@ const group = (codes, rail, nextStep) => { entries.push([code, guidance]); } }; -group("plugin-disabled", "R3/R6/R10: installed, pinned and enabled plugin capabilities", "Ask the instance owner to verify the installed plugin and the organization owner to enable it; then relaunch the agent."); +group("plugin-disabled", "R3/R6/R10: installed, pinned and enabled plugin capabilities", "Ask the instance owner to verify the installed plugin and the organization owner to enable it. A replacement of an already loaded package needs a host restart; then relaunch the agent."); +group("plugin-restart-required", "R3/R10: one imported package pin per host process", "Ask the instance owner to restart the host after replacing an already loaded package, then retry through the supported path."); group( "access-refused access-revoked share-exists share-not-covered share-not-found tester-dev-only tester-off tester-refused tester-required tester-target-not-running thread-not-allowed thread-not-owned", "R7: caller access and scoped session boundary", diff --git a/packages/organization/src/organization-config.mjs b/packages/organization/src/organization-config.mjs index 95eba2fb2..881efb102 100644 --- a/packages/organization/src/organization-config.mjs +++ b/packages/organization/src/organization-config.mjs @@ -13,6 +13,7 @@ import { loadRole } from "./role.mjs"; import { END_PROTECTED } from "./vocabulary.mjs"; import { checkPlugins, checkUsageSettings } from "../../plugins/src/kinds.mjs"; import { hasModule } from "../../plugins/src/loader.mjs"; +import { readPluginLock } from "../../plugins/src/pinned.mjs"; import { checkMapping } from "../../library/src/mapping.mjs"; import { forkProblem, giteaName, parseGiteaUrl, HOST_SERVICE, CREATIVE_DEFINITIONS } from "./vocabulary.mjs"; import { checkAppearance } from "./appearance.mjs"; @@ -333,8 +334,13 @@ export function validateOrganizationDocument(document, file, { rolesDir, dataRoo // here; whether each module loads is the loader's check, at load time. const plugins = document.plugins === undefined ? {} : structuredClone(checkPlugins(document.plugins, `${file} plugins`, { fail: (_code, message) => refuse(message) })); + let installedNames = new Set(); + if (dataRoot !== null && plugins["system-type"]?.some(id=>!hasModule("system-type",id))) { + try { installedNames = new Set(Object.keys(readPluginLock(dataRoot).plugins)); } + catch { installedNames = null; } // the loader reports an unreadable lock; core still loads + } for (const id of plugins["system-type"] ?? []) { - if (dataRoot === null && !hasModule("system-type", id)) refuse(`${file} plugins.system-type names unknown module ${id}`); + if (!hasModule("system-type", id) && installedNames !== null && !installedNames.has(id)) refuse(`${file} plugins.system-type names unknown module ${id}`); } const usage = document.usage === undefined ? undefined : checkUsageSettings(document.usage); const appearance = checkAppearance(document.appearance, `${file} appearance`); diff --git a/packages/plugins/README.md b/packages/plugins/README.md index 54b08a8e6..2e9c1efff 100644 --- a/packages/plugins/README.md +++ b/packages/plugins/README.md @@ -578,6 +578,19 @@ or network. They receive JSON on stdin and return JSON on stdout. The loader checks the complete package against the lock before every installed module import. A refusal logs static codes and the validated package name; core modules continue. A bad pin evicts previously loaded installed modules. +Once a package path has been imported, the process latches its verified pin before +the first import attempt (including failed attempts). Removing and reinstalling a +different pin at that path refuses with `plugin-restart-required` until the host +restarts. The latch is shared by loaders and host tools, so cached helper modules +cannot run under a replacement pin. A fresh sandbox tool process has no old cache. + +Executable dependencies must be bundled in the package and imported by relative +path (or an in-package file URL). A shared resolver guard refuses bare third-party +lookups, executable data URLs and paths outside that package; Node built-ins remain +available. This applies to static imports, dynamic imports and `createRequire`, +including sandbox tools. It constrains module resolution, not R10 host authority: +trusted host code can still use filesystem and other host APIs. + A role referencing a missing or disabled plugin refuses at launch with `plugin-disabled` and the package name. Disabling never fires or rewrites an agent; its next plugin tool call refuses with that same code. diff --git a/packages/plugins/src/imports.mjs b/packages/plugins/src/imports.mjs new file mode 100644 index 000000000..d18054642 --- /dev/null +++ b/packages/plugins/src/imports.mjs @@ -0,0 +1,35 @@ +// PL1: executable dependencies must be bundled inside the pinned package. +// This constrains module resolution, not the host authority granted by R10. +// Kept standalone so a sandbox tool can use this same guard in its fresh process. +import { isBuiltin, registerHooks } from 'node:module'; +import { isAbsolute, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const roots = new Set(); +let registered = false; +const inside = (root, path) => { + const rel = relative(root, path); + return rel !== '..' && !rel.startsWith('../') && !isAbsolute(rel); +}; +const refused = () => { throw new Error('plugin import refused'); }; +export function guardPluginImports(directory) { + roots.add(resolve(directory)); + if (registered) return; + registerHooks({resolve(specifier, context, nextResolve) { + let parent; + try { if (context.parentURL?.startsWith('file:')) parent = fileURLToPath(context.parentURL); } + catch { return refused(); } + const root = parent && [...roots].find(dir => inside(dir, parent)); + if (!root || isBuiltin(specifier)) return nextResolve(specifier, context); + // Refuse bare lookups before Node searches unpinned ancestor node_modules. + if (!specifier.startsWith('./') && !specifier.startsWith('../') && !specifier.startsWith('/') && !specifier.startsWith('file:')) return refused(); + try { + const candidate = new URL(specifier, context.parentURL); + if (candidate.protocol !== 'file:' || !inside(root, fileURLToPath(candidate))) return refused(); + const result = nextResolve(specifier, context); + if (!result.url.startsWith('file:') || !inside(root, fileURLToPath(result.url))) return refused(); + return result; + } catch { return refused(); } + }}); + registered = true; +} diff --git a/packages/plugins/src/loader.mjs b/packages/plugins/src/loader.mjs index eb83a139e..afa0177ef 100644 --- a/packages/plugins/src/loader.mjs +++ b/packages/plugins/src/loader.mjs @@ -13,7 +13,7 @@ import { lstatSync, readdirSync, realpathSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { KINDS, NAME, PluginError, namesFor } from "./kinds.mjs"; -import { verifiedPlugin, readPluginLock } from "./pinned.mjs"; +import { verifiedPlugin, readPluginLock, claimPluginImport } from "./pinned.mjs"; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -247,6 +247,7 @@ async function loadPinnedModule(spec, name, barrier, dataRoot) { barrier("checked", {kind:spec.kind,name}); checked = verifiedPlugin(dataRoot,name,spec.kind); const entry = join(checked.dir,spec.kind,"index.mjs"); + claimPluginImport(checked); let module; try { module=await import(pathToFileURL(entry).href + '?pin=' + checked.manifest.sha256); } catch { refuse("load-failed","installed plugin load refused",name); } diff --git a/packages/plugins/src/pinned.mjs b/packages/plugins/src/pinned.mjs index 020ba02c7..ec6d3c9df 100644 --- a/packages/plugins/src/pinned.mjs +++ b/packages/plugins/src/pinned.mjs @@ -4,6 +4,18 @@ import { constants, openSync, closeSync, fstatSync, lstatSync, readdirSync, read import { isAbsolute, join, resolve } from 'node:path'; import { NAME } from './kinds.mjs'; import { bad, checkManifest, HASH } from './manifest.mjs'; +import { guardPluginImports } from './imports.mjs'; +// Node caches transitive modules by canonical path. Never attribute old cached +// code to a replacement pin, even after a failed import or a new loader instance. +const importedPins = new Map(); +function checkImportedPin(dir, hash, name) { + if (importedPins.has(dir) && importedPins.get(dir) !== hash) bad('plugin-restart-required',name); +} +export function claimPluginImport({dir,manifest}) { + checkImportedPin(dir,manifest.sha256,manifest.name); + guardPluginImports(dir); + importedPins.set(dir,manifest.sha256); // before the first import attempt +} export function pluginPaths(dataRoot) { if (typeof dataRoot !== 'string' || !isAbsolute(dataRoot) || resolve(dataRoot) !== dataRoot) bad('plugin-root'); return {root:join(dataRoot,'plugins'),lock:join(dataRoot,'plugins.lock.json'),mutex:join(dataRoot,'plugins.install.lock')}; @@ -79,16 +91,17 @@ export function readPluginLock(dataRoot) { try { const st=lstatSync(lock); if (st.isSymbolicLink() || !st.isFile() || st.uid!==process.getuid() || st.mode&0o022) bad('invalid-lock'); doc=JSON.parse(readRegular(lock,st)); } catch (e) { if(e.code==='ENOENT') return {version:1,plugins:{}}; bad('invalid-lock'); } if (!doc || doc.version!==1 || Object.keys(doc).some(k=>!['version','plugins'].includes(k)) || !doc.plugins || Array.isArray(doc.plugins) || typeof doc.plugins!=='object') bad('invalid-lock'); - for(const [n,p] of Object.entries(doc.plugins)) if(!NAME.test(n) || !p || Object.keys(p).sort().join(',')!=='sha256,source,version' || !HASH.test(p.sha256) || typeof p.version!=='string') bad('invalid-lock'); + for(const [n,p] of Object.entries(doc.plugins)) if(n.length>32 || !NAME.test(n) || !p || Object.keys(p).sort().join(',')!=='sha256,source,version' || !HASH.test(p.sha256) || typeof p.version!=='string') bad('invalid-lock'); return doc; } export function verifiedPlugin(dataRoot,name,kind=null) { - if(typeof name!=='string' || !NAME.test(name)) bad('bad-name'); + if(typeof name!=='string' || name.length>32 || !NAME.test(name)) bad('bad-name'); try { const pin=readPluginLock(dataRoot).plugins[name]; if(!pin) bad('missing-lock',name); const {root}=pluginPaths(dataRoot); const dir=join(root,name); const result=readPlugin(dir); if(result.manifest.name!==name || result.manifest.sha256!==pin.sha256 || result.manifest.version!==pin.version) bad('hash-mismatch',name); if(kind && !result.manifest.kinds.includes(kind)) bad('unknown-kind',name); + checkImportedPin(dir,result.manifest.sha256,name); return {...result,dir}; - } catch(e) { bad(['missing-lock','hash-mismatch','unknown-kind','unsafe-path','invalid-lock'].includes(e.code)?e.code:'plugin-unavailable',name); } + } catch(e) { bad(['missing-lock','hash-mismatch','unknown-kind','unsafe-path','invalid-lock','plugin-restart-required'].includes(e.code)?e.code:'plugin-unavailable',name); } } diff --git a/packages/plugins/src/tools.mjs b/packages/plugins/src/tools.mjs index 3183a62da..2363483d5 100644 --- a/packages/plugins/src/tools.mjs +++ b/packages/plugins/src/tools.mjs @@ -3,11 +3,12 @@ import { spawn } from 'node:child_process'; import { existsSync, realpathSync } from 'node:fs'; import { join } from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { pathToFileURL, fileURLToPath } from 'node:url'; import { organizationPlugins, assertPluginCapabilities, pluginName } from './organization.mjs'; -import { verifiedPlugin } from './pinned.mjs'; +import { verifiedPlugin, claimPluginImport } from './pinned.mjs'; import { bad, validArguments } from './manifest.mjs'; const SCRIPT = `let input=''; for await(const b of process.stdin)input+=b; +const {guardPluginImports}=await import('file:///plugin-guard.mjs');guardPluginImports('/plugin'); const {entry,args}=JSON.parse(input); const m=await import(entry); process.stdout.write(JSON.stringify(await m.execute(args)));`; function sandboxTool(dir,entry,args) { @@ -15,7 +16,7 @@ function sandboxTool(dir,entry,args) { // No home, config, workspace, sockets or inherited environment is exposed. const binds=['/usr','/lib','/lib64','/bin'].filter(existsSync).flatMap(p=>['--ro-bind',realpathSync(p),p]); return new Promise((resolve,reject)=>{ - const child=spawn('bwrap',['--die-with-parent','--unshare-all','--new-session','--proc','/proc','--dev','/dev','--tmpfs','/tmp',...binds,'--ro-bind',realpathSync(process.execPath),'/node','--ro-bind',dir,'/plugin','--chdir','/plugin','--clearenv','/node','--input-type=module','-e',SCRIPT],{env:{PATH:'/usr/bin:/bin'},stdio:['pipe','pipe','pipe']}); + const child=spawn('bwrap',['--die-with-parent','--unshare-all','--new-session','--proc','/proc','--dev','/dev','--tmpfs','/tmp',...binds,'--ro-bind',realpathSync(process.execPath),'/node','--ro-bind',fileURLToPath(new URL('./imports.mjs',import.meta.url)),'/plugin-guard.mjs','--ro-bind',dir,'/plugin','--chdir','/plugin','--clearenv','/node','--input-type=module','-e',SCRIPT],{env:{PATH:'/usr/bin:/bin'},stdio:['pipe','pipe','pipe']}); let out='',size=0;const timer=setTimeout(()=>child.kill('SIGKILL'),30000); child.stdout.on('data',b=>{size+=b.length;if(size>1024*1024)child.kill('SIGKILL');else out+=b;}); child.stderr.resume();child.on('error',()=>{clearTimeout(timer);reject(new Error('sandbox refused'));}); @@ -40,11 +41,12 @@ export function pluginService({dataRoot,read}) { if(!role.tools?.includes(name))bad('not-authorized'); const tool=view.manifests.flatMap(m=>m.tools).find(t=>t.name===name);if(!tool)bad('plugin-disabled',prefix); if(!validArguments(args,tool.arguments))bad('invalid-request'); - const {dir,manifest}=verifiedPlugin(dataRoot,prefix); + const checked=verifiedPlugin(dataRoot,prefix),{dir,manifest}=checked; if(tool.runs==='sandbox') { try {return await sandboxTool(dir,tool.entry,structuredClone(args));} catch {bad('plugin-tool-failed',prefix);} } + claimPluginImport(checked); let module; try {module=await import(pathToFileURL(join(dir,tool.entry)).href+`?pin=${manifest.sha256}`);} catch {bad('plugin-tool-failed',prefix);} diff --git a/packages/plugins/tests/pinned.test.mjs b/packages/plugins/tests/pinned.test.mjs index f4c5c547a..a5fc2e7d2 100644 --- a/packages/plugins/tests/pinned.test.mjs +++ b/packages/plugins/tests/pinned.test.mjs @@ -210,4 +210,89 @@ test('PL1 A3 organization copy readers preserve the derived installed-plugin roo const options={rolesDir,dataRoot:f.dataRoot,snapshot:null}; assert.deepEqual(withCopies(doc,join(f.root,'businesses/acme.json'),options).plugins['system-type'],['acmeplug']); assert.deepEqual(loadWithCopies('acme',{...options,dir:f.root}).plugins['system-type'],['acmeplug']); + const installed=join(f.dataRoot,'plugins/acmeplug/system-type/index.mjs');chmodSync(installed,0o600);writeFileSync(installed,SECRET); + // Config checks names only; the loader owns bad-pin refusal and preserves core. + assert.deepEqual(withCopies(doc,join(f.root,'businesses/acme.json'),options).plugins['system-type'],['acmeplug']); + doc.plugins['system-type']=['defualt']; + assert.throws(()=>withCopies(doc,join(f.root,'businesses/acme.json'),options),/unknown module/); +}); + +test('PL1 replacement pins refuse across loader and host tools until a fresh process',async t=>{ + for(const first of ['loader','tool']) { + const f=fixture(t); + writeFileSync(join(f.src,'helper.mjs'),'export const value=1;'); + writeFileSync(join(f.src,'usage-source/index.mjs'),"import {value} from '../helper.mjs';export const interfaceVersion=1;export const readTurn=()=>value;"); + writeFileSync(join(f.src,'tools/echo.mjs'),"import {value} from '../helper.mjs';export const execute=()=>value;"); + const pin=f.seal();f.install(f.src,{expectedHash:pin}); + const settings={'usage-source':['transcript','acmeplug'],harness:['pi']}; + const loader=createLoader({dataRoot:f.dataRoot,log:()=>{}}); + const service=pluginService({dataRoot:f.dataRoot,read:()=>({plugins:settings,roles:{writer:{tools:['acmeplug.echo'],authority:{withinRole:[],crossRole:[]}}}})}); + if(first==='loader'){await loader.load(settings);assert.equal(loader.modules('usage-source')[1].readTurn(),1);} + else assert.equal(await service.call('acme','writer','acmeplug.echo',{text:'before'}),1); + removePlugin({dataRoot:f.dataRoot,name:'acmeplug',enabled:()=>false}); + writeFileSync(join(f.src,'helper.mjs'),'export const value=2;');f.manifest.version='2.0.0'; + const next=f.seal();assert.notEqual(next,pin);f.install(f.src,{expectedHash:next}); + // Verification, new loaders, and the other import path share one process latch. + assert.throws(()=>verifiedPlugin(f.dataRoot,'acmeplug'),refused('plugin-restart-required')); + await assert.rejects(service.call('acme','writer','acmeplug.echo',{text:'after'}),refused('plugin-disabled')); + const freshLoader=createLoader({dataRoot:f.dataRoot,log:()=>{}}),result=await freshLoader.load(settings); + assert.equal(result.refused[0].code,'plugin-restart-required'); + assert.deepEqual(result.loaded['usage-source'],['transcript']);assert.deepEqual(result.loaded.harness,['pi']); + await loader.load(null);assert.deepEqual(loader.loaded()['usage-source'],first==='loader'?['transcript']:[]); + const script=`import {createLoader} from ${JSON.stringify(import.meta.resolve('../src/loader.mjs'))}; +const loader=createLoader({dataRoot:process.argv[1],log:()=>{}});const r=await loader.load({'usage-source':['acmeplug']}); +console.log(JSON.stringify({refused:r.refused,value:loader.modules('usage-source')[0].readTurn()}));`; + const child=spawnSync(process.execPath,['--input-type=module','-e',script,f.dataRoot],{encoding:'utf8'}); + assert.equal(child.status,0,child.stderr);assert.deepEqual(JSON.parse(child.stdout),{refused:[],value:2}); + } +}); + +test('PL1 even a failed import claims its package pin before dependencies enter the cache',async t=>{ + const f=fixture(t);writeFileSync(join(f.src,'helper.mjs'),'export const value=1;'); + writeFileSync(join(f.src,'usage-source/index.mjs'),"import '../helper.mjs';throw Error('fixture');"); + let pin=f.seal();f.install(f.src,{expectedHash:pin}); + const loader=createLoader({dataRoot:f.dataRoot,log:()=>{}}); + assert.equal((await loader.load({'usage-source':['acmeplug']})).refused[0].code,'load-failed'); + removePlugin({dataRoot:f.dataRoot,name:'acmeplug',enabled:()=>false}); + writeFileSync(join(f.src,'usage-source/index.mjs'),'export const interfaceVersion=1;export const readTurn=()=>2;'); + pin=f.seal();f.install(f.src,{expectedHash:pin}); + assert.equal((await loader.load({'usage-source':['transcript','acmeplug']})).refused[0].code,'plugin-restart-required'); + assert.deepEqual(loader.loaded()['usage-source'],['transcript']); +}); + +test('PL1 plugin imports refuse unpinned bare, escaping, dynamic and require dependencies',async t=>{ + for(const spec of ['extdep','../../../outside.mjs']) { + const f=fixture(t),marker=join(f.dataRoot,'executed'); + const outside=`import {writeFileSync} from 'node:fs';writeFileSync(${JSON.stringify(marker)},'ran');export const value=1;`; + mkdirSync(join(f.dataRoot,'node_modules/extdep'),{recursive:true}); + writeFileSync(join(f.dataRoot,'node_modules/extdep/package.json'),JSON.stringify({type:'module',main:'index.mjs'})); + writeFileSync(join(f.dataRoot,'node_modules/extdep/index.mjs'),outside);writeFileSync(join(f.dataRoot,'outside.mjs'),outside); + writeFileSync(join(f.src,'usage-source/index.mjs'),`import ${JSON.stringify(spec)};export const interfaceVersion=1;export const readTurn=()=>1;`); + const pin=f.seal();f.install(f.src,{expectedHash:pin}); + const result=await createLoader({dataRoot:f.dataRoot,log:()=>{}}).load({'usage-source':['transcript','acmeplug'],harness:['pi']}); + assert.equal(result.refused[0].code,'load-failed');assert.deepEqual(result.loaded['usage-source'],['transcript']);assert.deepEqual(result.loaded.harness,['pi']);assert.equal(existsSync(marker),false); + } + for(const body of ["return (await import('../../../outside.mjs')).value;","return require('../../../outside.cjs');"]) { + const f=fixture(t),marker=join(f.dataRoot,'executed'); + writeFileSync(join(f.dataRoot,'outside.mjs'),`import {writeFileSync} from 'node:fs';writeFileSync(${JSON.stringify(marker)},'ran');export const value=1;`); + writeFileSync(join(f.dataRoot,'outside.cjs'),`require('node:fs').writeFileSync(${JSON.stringify(marker)},'ran');module.exports=1;`); + writeFileSync(join(f.src,'tools/echo.mjs'),`import {createRequire} from 'node:module';const require=createRequire(import.meta.url);export async function execute(){${body}}`); + const pin=f.seal();f.install(f.src,{expectedHash:pin}); + const service=pluginService({dataRoot:f.dataRoot,read:()=>({plugins:{'usage-source':['acmeplug']},roles:{writer:{tools:['acmeplug.echo'],authority:{withinRole:[],crossRole:[]}}}})}); + await assert.rejects(service.call('acme','writer','acmeplug.echo',{text:SECRET}),refused('plugin-tool-failed'));assert.equal(existsSync(marker),false); + } +}); + +test('PL1 sandbox tools also refuse executable data URLs outside the pinned package',async t=>{ + const f=fixture(t);f.manifest.tools[0].runs='sandbox'; + writeFileSync(join(f.src,'tools/echo.mjs'),"export async function execute(){return (await import('data:text/javascript,export default 42')).default;}"); + const pin=f.seal();f.install(f.src,{expectedHash:pin}); + const service=pluginService({dataRoot:f.dataRoot,read:()=>({plugins:{'usage-source':['acmeplug']},roles:{writer:{tools:['acmeplug.echo'],authority:{withinRole:[],crossRole:[]}}}})}); + await assert.rejects(service.call('acme','writer','acmeplug.echo',{text:SECRET}),refused('plugin-tool-failed')); +}); + +test('PL1 lock package names use the manifest length bound',t=>{ + const f=fixture(t);writeFileSync(join(f.dataRoot,'plugins.lock.json'),JSON.stringify({version:1,plugins:{['a'.repeat(33)]:{version:'1.0.0',source:f.manifest.source,sha256:f.hash}}}),{mode:0o600}); + assert.throws(()=>readPluginLock(f.dataRoot),refused('invalid-lock')); + assert.throws(()=>verifiedPlugin(f.dataRoot,'a'.repeat(33)),refused('bad-name')); }); -- 2.54.0