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')); });