74 lines
5.9 KiB
JavaScript
74 lines
5.9 KiB
JavaScript
// 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);
|
|
}
|