// Offline manifest-policy example. It parses declarations; it does not load or execute an extension. import {readFile} from 'node:fs/promises' import {fileURLToPath} from 'node:url' const hex=(value,length)=>typeof value==='string'&&new RegExp(`^[0-9a-f]{${length}}$`).test(value) const nonblank=value=>typeof value==='string'&&value.trim().length>0 const strings=value=>Array.isArray(value)&&value.every(nonblank) const record=value=>value!==null&&typeof value==='object'&&!Array.isArray(value) const safeRelativePath=value=>nonblank(value)&&!value.includes('\\')&&!value.startsWith('/')&&!/^[A-Za-z]:/.test(value)&&value.split('/').every(segment=>segment!==''&&segment!=='.'&&segment!=='..') const rejectUnknown=(value,allowed,label,errors)=>{ if(!record(value))return for(const key of Object.keys(value))if(!allowed.has(key))errors.push(`unknown ${label} field: ${key}`) } const manifestKeys=new Set(['schema','id','version','kind','entryPoints','executable','declaredCapabilities','requestedCapabilities','hooks','dependencies','provenance','license','updateChannel','rollback']) const policyKeys=new Set(['schema','allowedKinds','allowedCapabilities','requirePinnedExecutableUpdates','requireNoDependencies']) const provenanceKeys=new Set(['source','revision','artifactSha256']) const updateKeys=new Set(['mode','reference']) export function validateManifest(manifest,policy){ const errors=[] if(!record(manifest))return {id:null,decision:'reject',errors:['manifest must be an object']} if(!record(policy))return {id:nonblank(manifest.id)?manifest.id:null,decision:'reject',errors:['policy must be an object']} rejectUnknown(manifest,manifestKeys,'manifest',errors) rejectUnknown(policy,policyKeys,'policy',errors) if(manifest?.schema!=='martybytes-agent-extension/v1')errors.push('unsupported manifest schema') if(policy?.schema!=='martybytes-agent-extension-policy/v1')errors.push('unsupported policy schema') if(!nonblank(manifest.id))errors.push('id must be a non-blank string') if(!nonblank(manifest.version))errors.push('version must be a non-blank string') const allowedKinds=strings(policy.allowedKinds)?policy.allowedKinds:[] const allowedCapabilities=strings(policy.allowedCapabilities)?policy.allowedCapabilities:[] if(!strings(policy.allowedKinds))errors.push('policy allowedKinds must be a string array') if(!strings(policy.allowedCapabilities))errors.push('policy allowedCapabilities must be a string array') if(typeof policy.requirePinnedExecutableUpdates!=='boolean')errors.push('policy requirePinnedExecutableUpdates must be boolean') if(typeof policy.requireNoDependencies!=='boolean')errors.push('policy requireNoDependencies must be boolean') if(!allowedKinds.includes(manifest.kind))errors.push(`kind not allowed: ${manifest.kind??'missing'}`) const entryPoints=strings(manifest.entryPoints)?manifest.entryPoints:[] const declaredCapabilities=strings(manifest.declaredCapabilities)?manifest.declaredCapabilities:[] const requestedCapabilities=strings(manifest.requestedCapabilities)?manifest.requestedCapabilities:[] const hooks=strings(manifest.hooks)?manifest.hooks:[] const dependencies=strings(manifest.dependencies)?manifest.dependencies:[] if(!strings(manifest.entryPoints)||manifest.entryPoints.length===0)errors.push('entryPoints must be a non-empty string array') else if(entryPoints.some(entry=>!safeRelativePath(entry)))errors.push('entryPoints must contain safe relative paths') if(!strings(manifest.declaredCapabilities))errors.push('declaredCapabilities must be a string array') if(!strings(manifest.requestedCapabilities))errors.push('requestedCapabilities must be a string array') if(!strings(manifest.hooks))errors.push('hooks must be a string array') if(!strings(manifest.dependencies))errors.push('dependencies must be a string array') const declared=new Set(declaredCapabilities) const allowed=new Set(allowedCapabilities) for(const capability of requestedCapabilities){ if(!declared.has(capability))errors.push(`requested capability is undeclared: ${capability}`) if(!allowed.has(capability))errors.push(`requested capability is denied by policy: ${capability}`) } if(manifest?.kind==='document-only'){ if(manifest.executable!==false)errors.push('document-only extension must set executable=false') if(entryPoints.some(entry=>!entry.endsWith('.md')&&!entry.endsWith('.txt')))errors.push('document-only entry points must be .md or .txt') if(requestedCapabilities.length)errors.push('document-only fixture may not request capabilities') if(hooks.length)errors.push('document-only fixture may not declare hooks') if(dependencies.length)errors.push('document-only fixture may not declare dependencies') } if(manifest?.kind==='executable-plugin'){ if(manifest.executable!==true)errors.push('executable plugin must set executable=true') if(policy?.requirePinnedExecutableUpdates&&manifest?.updateChannel?.mode!=='pinned')errors.push('executable plugin update channel must be pinned') } if(policy.requireNoDependencies&&dependencies.length)errors.push('dependencies are denied by this lab policy') if(!record(manifest.provenance))errors.push('provenance must be an object') else{ rejectUnknown(manifest.provenance,provenanceKeys,'provenance',errors) if(!nonblank(manifest.provenance.source)||!hex(manifest.provenance.revision,40)||!hex(manifest.provenance.artifactSha256,64))errors.push('provenance source, 40-character revision, and SHA-256 are required') } if(!record(manifest.updateChannel))errors.push('updateChannel must be an object') else{ rejectUnknown(manifest.updateChannel,updateKeys,'updateChannel',errors) if(!nonblank(manifest.updateChannel.mode)||!nonblank(manifest.updateChannel.reference))errors.push('updateChannel mode and reference are required') } if(!nonblank(manifest.license))errors.push('license is required') if(!nonblank(manifest.rollback))errors.push('rollback procedure is required') return {id:nonblank(manifest.id)?manifest.id:null,decision:errors.length?'reject':'allow',errors} } async function main(args){ if(args.length<2)throw new Error('Usage: node agent-extension-validator.mjs POLICY.json MANIFEST.json [MANIFEST.json ...]') const [policyPath,...manifestPaths]=args const policy=JSON.parse(await readFile(policyPath,'utf8')) const results=[] for(const manifestPath of manifestPaths)results.push(validateManifest(JSON.parse(await readFile(manifestPath,'utf8')),policy)) console.log(JSON.stringify({schema:'martybytes-agent-extension-review/v1',results},null,2)) if(results.some(result=>result.decision==='reject'))process.exitCode=2 } if(process.argv[1]===fileURLToPath(import.meta.url))main(process.argv.slice(2)).catch(error=>{console.error(error.message);process.exitCode=1})