Files
stack/packages/plugins/src/tools.mjs
T

60 lines
3.9 KiB
JavaScript

// 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};
}