fix(plugins): keep imports on the verified package pin (#1873)

This commit was merged in pull request #1924.
This commit is contained in:
jason.woltje committed 2026-11-11 00:11:13 -06:00
1 parent b44b14c7df
commit 99ce579e3c
8 files changed
+166 -10

No files matched your search

+2 -1
View File
@@ -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",
@@ -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`);
+13
View File
@@ -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.
+35
View File
@@ -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;
}
+2 -1
View File
@@ -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); }
+16 -3
View File
@@ -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); }
}
+6 -4
View File
@@ -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);}
+85
View File
@@ -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'));
});