524 lines
25 KiB
JavaScript
524 lines
25 KiB
JavaScript
// Roles round 2, slice P1 (#1873): the primitives registry and the composition validator.
|
|
// Design: docs/plans/2026-11-10_roles-and-rails-design.md sections 3, 4 and 7, and the P1
|
|
// paragraph of section 13.
|
|
//
|
|
// A role is a composition of the primitives in section 4 and nothing else: tools, network,
|
|
// authority, credentials, messaging, skills, instructions and personality. checkComposition
|
|
// checks one against a registry (the core set from vocabulary.mjs plus a plugin-declared set) in
|
|
// the seven steps section 4 lists, in order, and returns the lines a human must see flagged.
|
|
//
|
|
// The broker and approved writer validate every hire with this registry.
|
|
//
|
|
// A refusal names the field and the position at fault. It quotes a value only when that value is
|
|
// a registered name (a core or plugin action, tool, network, service or scope), so no role text,
|
|
// and no secret placed where a name belongs, reaches an error. Free text (instructions,
|
|
// personality) is never quoted.
|
|
|
|
import { refuse } from "./errors.mjs";
|
|
import {
|
|
ACTIONS, ROUTED, GATED_ONLY, TOOLS, NETWORKS, SERVICES, HOST_SERVICE, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
|
|
ID_PATTERN,
|
|
} from "./vocabulary.mjs";
|
|
import { NAME as PLUGIN_NAME } from "../../plugins/src/kinds.mjs";
|
|
import { deepFreeze } from "./util.mjs";
|
|
|
|
// The primitives (section 4). Every other key refuses (step 1).
|
|
export const COMPOSITION_KEYS = Object.freeze([
|
|
"tools", "network", "authority", "credentials", "messaging", "skills", "instructions", "personality",
|
|
]);
|
|
const REQUIRED = Object.freeze(["tools", "network", "authority", "credentials", "instructions"]);
|
|
const AUTHORITY_KEYS = Object.freeze(["withinRole", "crossRole"]);
|
|
const MESSAGING_KEYS = Object.freeze(["sendTo", "receiveFrom"]);
|
|
const PERSONALITY_KEYS = Object.freeze(["summary", "traits", "voice"]);
|
|
const CREDENTIAL_KEYS = Object.freeze(["service", "scopes"]);
|
|
const PLUGIN_KEYS = Object.freeze(["name", "tools", "actions", "services", "roleTemplates"]);
|
|
const PLUGIN_SERVICE_KEYS = Object.freeze(["name", "scopes"]);
|
|
|
|
// Step 6's length bounds, in characters or entries. The design gives none, so P1 sets them. The
|
|
// largest shipped contract is about 8 KB; instructions get twice that. T1 stores personality
|
|
// with these same bounds.
|
|
export const COMPOSITION_BOUNDS = Object.freeze({
|
|
instructions: 16384,
|
|
summary: 280,
|
|
traits: 12,
|
|
trait: 40,
|
|
voice: 280,
|
|
messaging: 64,
|
|
skills: 64,
|
|
});
|
|
|
|
// Ruling 8 and R9: a hired agent cannot hire, fire or change another role.
|
|
export const NEVER_HIRED = Object.freeze(["role.hire", "role.fire", "role.change"]);
|
|
// The role rails (section 3): an action that lets its holder decide for other agents.
|
|
export const RESOLVE_PREFIX = "decision.resolve.";
|
|
|
|
export const FLAG_RESOLVE = "resolve-right";
|
|
export const FLAG_ABOVE_CHIEF = "above-chief";
|
|
|
|
const SKILL_NAME = /^[A-Za-z0-9._-]+$/;
|
|
const GITEA_SCOPE = /^(read|write):([a-z]+)$/;
|
|
// A plugin's declared bare name: lower-case segments joined by dots. With the prefix it is
|
|
// <plugin>.<name>.
|
|
const DECLARED_NAME = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
|
|
const DECLARED_SCOPE = /^[a-z][a-z0-9_.:-]*$/;
|
|
const NAME_MAX = 64;
|
|
|
|
const isPlain = (value) => {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
const proto = Object.getPrototypeOf(value);
|
|
return proto === Object.prototype || proto === null;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// The registry.
|
|
|
|
const BUILT = new WeakSet();
|
|
|
|
// The first segment of every core action, tool and service. A plugin may not take one as its
|
|
// name, so a prefixed plugin name can never read as a core name (git + push.protected).
|
|
const RESERVED_PLUGIN_NAMES = Object.freeze([...new Set([
|
|
...ACTIONS.map((a) => a.split(".")[0]), ...TOOLS, ...SERVICES, HOST_SERVICE, ...NETWORKS,
|
|
])]);
|
|
|
|
function declaredList(value, where, pattern) {
|
|
if (value === undefined) return [];
|
|
if (!Array.isArray(value)) refuse(`${where} must be an array`);
|
|
const seen = new Set();
|
|
value.forEach((entry, i) => {
|
|
if (typeof entry !== "string" || entry.length > NAME_MAX || !pattern.test(entry)) {
|
|
refuse(`${where}[${i}] must be a name matching ${pattern}`);
|
|
}
|
|
if (seen.has(entry)) refuse(`${where}[${i}] repeats an earlier entry`);
|
|
seen.add(entry);
|
|
});
|
|
return [...seen];
|
|
}
|
|
|
|
// One plugin's declared set: { name, tools?: [name], actions?: [name], services?: [{ name, scopes:
|
|
// [scope] }] }. Names are declared bare and registered with the plugin's prefix. A plugin action is
|
|
// always routed (section 4), so it may not name a hard wait, and no declared name may reuse a core
|
|
// name, bare or prefixed.
|
|
function checkPlugin(plugin, i, core) {
|
|
const where = `registry plugins[${i}]`;
|
|
if (!isPlain(plugin)) refuse(`${where} must be a JSON object`);
|
|
for (const key of Object.keys(plugin)) {
|
|
if (!PLUGIN_KEYS.includes(key)) refuse(`${where} has a key outside ${PLUGIN_KEYS.join(", ")}`);
|
|
}
|
|
const { name } = plugin;
|
|
if (typeof name !== "string" || name.length > 32 || !PLUGIN_NAME.test(name)) refuse(`${where}.name must match ${PLUGIN_NAME}`);
|
|
if (RESERVED_PLUGIN_NAMES.includes(name)) refuse(`${where}.name ${JSON.stringify(name)} is a core name`);
|
|
const full = (bare) => `${name}.${bare}`;
|
|
const coreName = (bare) => core.has(bare) || core.has(full(bare));
|
|
const tools = declaredList(plugin.tools, `${where}.tools`, DECLARED_NAME);
|
|
tools.forEach((bare, j) => {
|
|
if (coreName(bare)) refuse(`${where}.tools[${j}] reuses the core name ${JSON.stringify(core.has(bare) ? bare : full(bare))}`);
|
|
});
|
|
const actions = declaredList(plugin.actions, `${where}.actions`, DECLARED_NAME);
|
|
actions.forEach((bare, j) => {
|
|
if (GATED_ONLY.includes(bare)) refuse(`${where}.actions[${j}] declares ${bare}, a hard wait; a plugin action is always routed`);
|
|
if (coreName(bare)) refuse(`${where}.actions[${j}] reuses the core name ${JSON.stringify(core.has(bare) ? bare : full(bare))}`);
|
|
});
|
|
if (plugin.services !== undefined && !Array.isArray(plugin.services)) refuse(`${where}.services must be an array`);
|
|
const services = (plugin.services ?? []).map((service, j) => {
|
|
const at = `${where}.services[${j}]`;
|
|
if (!isPlain(service)) refuse(`${at} must be a JSON object`);
|
|
for (const key of Object.keys(service)) {
|
|
if (!PLUGIN_SERVICE_KEYS.includes(key)) refuse(`${at} has a key outside ${PLUGIN_SERVICE_KEYS.join(", ")}`);
|
|
}
|
|
const [bare] = declaredList([service.name], `${at}.name`, DECLARED_NAME);
|
|
if (coreName(bare)) refuse(`${at}.name reuses the core name ${JSON.stringify(core.has(bare) ? bare : full(bare))}`);
|
|
const scopes = declaredList(service.scopes, `${at}.scopes`, DECLARED_SCOPE);
|
|
if (scopes.length === 0) refuse(`${at}.scopes must not be empty`);
|
|
return { name: full(bare), scopes };
|
|
});
|
|
const names = [...tools, ...actions, ...services.map((s) => s.name.slice(name.length + 1))];
|
|
const twice = names.find((n, j) => names.indexOf(n) !== j);
|
|
if (twice !== undefined) refuse(`${where} declares ${JSON.stringify(full(twice))} twice`);
|
|
if (plugin.roleTemplates !== undefined && !Array.isArray(plugin.roleTemplates)) refuse("invalid plugin role templates");
|
|
const roleTemplates=(plugin.roleTemplates ?? []).map(t=>{
|
|
if(!isPlain(t) || Object.keys(t).sort().join(",")!=="file,name" || typeof t.name!=="string" || !DECLARED_NAME.test(t.name) || coreName(t.name)
|
|
|| typeof t.file!=="string" || !/^[a-zA-Z0-9._/-]+$/.test(t.file) || t.file.split("/").some(p=>!p || p==="." || p==="..")) refuse("invalid plugin role template");
|
|
return {name:full(t.name),file:t.file};
|
|
});
|
|
if(new Set(roleTemplates.map(t=>t.name)).size!==roleTemplates.length)refuse("duplicate plugin role template");
|
|
return { name, tools: tools.map(full), actions: actions.map(full), services, roleTemplates };
|
|
}
|
|
|
|
// The registry checkComposition reads: the core set plus each plugin's declared set. The plugin
|
|
// set is the caller's input and empty by default (PL1 passes the plugins enabled in the
|
|
// organization). checkComposition accepts only a registry built here.
|
|
export function buildRegistry({ plugins = [] } = {}) {
|
|
if (!Array.isArray(plugins)) refuse("registry plugins must be an array");
|
|
const core = new Set([...ACTIONS, ...TOOLS, ...SERVICES, HOST_SERVICE]);
|
|
const declared = plugins.map((plugin, i) => checkPlugin(plugin, i, core));
|
|
const pluginNames = declared.map((p) => p.name);
|
|
const twice = pluginNames.find((n, j) => pluginNames.indexOf(n) !== j);
|
|
if (twice !== undefined) refuse(`registry names plugin ${JSON.stringify(twice)} twice`);
|
|
const services = Object.create(null);
|
|
for (const name of SERVICES) services[name] = { kind: name };
|
|
services[HOST_SERVICE] = { kind: HOST_SERVICE };
|
|
for (const p of declared) for (const s of p.services) services[s.name] = { kind: "plugin", scopes: s.scopes };
|
|
const pluginActions = declared.flatMap((p) => p.actions);
|
|
const registry = deepFreeze({
|
|
tools: [...TOOLS, ...declared.flatMap((p) => p.tools)],
|
|
networks: [...NETWORKS],
|
|
actions: [...ACTIONS, ...pluginActions],
|
|
routed: [...ROUTED, ...pluginActions],
|
|
services,
|
|
plugins: declared,
|
|
roleTemplates: declared.flatMap(p=>p.roleTemplates),
|
|
});
|
|
BUILT.add(registry);
|
|
return registry;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// The validator.
|
|
|
|
function keysWithin(object, allowed, where) {
|
|
Object.keys(object).forEach((key, i) => {
|
|
if (!allowed.includes(key)) refuse(`composition ${where} has an unknown key (key ${i + 1}; allowed: ${allowed.join(", ")})`);
|
|
});
|
|
}
|
|
|
|
// A list of distinct strings. Entries are named by position only: this runs before anything is
|
|
// known to be a registered name.
|
|
function stringList(value, where, { nonEmpty = false } = {}) {
|
|
if (!Array.isArray(value)) refuse(`composition ${where} must be an array`);
|
|
if (nonEmpty && value.length === 0) refuse(`composition ${where} must not be empty`);
|
|
const seen = new Set();
|
|
for (let i = 0; i < value.length; i += 1) {
|
|
if (typeof value[i] !== "string" || value[i].includes("\0")) refuse(`composition ${where}[${i}] must be a string`);
|
|
if (seen.has(value[i])) refuse(`composition ${where}[${i}] repeats an earlier entry`);
|
|
seen.add(value[i]);
|
|
}
|
|
return [...seen];
|
|
}
|
|
|
|
function text(value, where) {
|
|
if (typeof value !== "string" || value.includes("\0")) refuse(`composition ${where} must be a string`);
|
|
return value;
|
|
}
|
|
|
|
// Step 1: the shape, with every unknown key refused. Returns a fresh copy; the input is not read
|
|
// again after this.
|
|
function shape(role) {
|
|
if (!isPlain(role)) refuse("composition must be a JSON object");
|
|
keysWithin(role, COMPOSITION_KEYS, "role");
|
|
for (const key of REQUIRED) {
|
|
if (role[key] === undefined) refuse(`composition requires "${key}"`);
|
|
}
|
|
const out = {};
|
|
out.tools = stringList(role.tools, "tools", { nonEmpty: true });
|
|
if (typeof role.network !== "string") refuse("composition network must be a string");
|
|
out.network = role.network;
|
|
|
|
if (!isPlain(role.authority)) refuse("composition authority must be a JSON object");
|
|
keysWithin(role.authority, AUTHORITY_KEYS, "authority");
|
|
out.authority = {};
|
|
for (const key of AUTHORITY_KEYS) out.authority[key] = stringList(role.authority[key], `authority.${key}`);
|
|
out.authority.crossRole.forEach((action, i) => {
|
|
if (out.authority.withinRole.includes(action)) refuse(`composition authority.crossRole[${i}] is also in authority.withinRole`);
|
|
});
|
|
|
|
if (!Array.isArray(role.credentials)) refuse("composition credentials must be an array");
|
|
out.credentials = role.credentials.map((entry, i) => {
|
|
const where = `credentials[${i}]`;
|
|
if (!isPlain(entry)) refuse(`composition ${where} must be a JSON object`);
|
|
keysWithin(entry, CREDENTIAL_KEYS, where);
|
|
if (typeof entry.service !== "string") refuse(`composition ${where}.service must be a string`);
|
|
const copy = { service: entry.service };
|
|
if (entry.scopes !== undefined) {
|
|
if (Array.isArray(entry.scopes)) {
|
|
copy.scopes = stringList(entry.scopes, `${where}.scopes`);
|
|
} else if (isPlain(entry.scopes)) {
|
|
// No prototype, so a "__proto__" group stays a key and step 2 refuses it.
|
|
copy.scopes = Object.create(null);
|
|
Object.keys(entry.scopes).forEach((group, j) => {
|
|
if (group.includes("\0")) refuse(`composition ${where}.scopes key ${j + 1} must be a string`);
|
|
copy.scopes[group] = stringList(entry.scopes[group], `${where}.scopes (group ${j + 1})`);
|
|
});
|
|
} else {
|
|
refuse(`composition ${where}.scopes must be an array or a JSON object`);
|
|
}
|
|
}
|
|
return copy;
|
|
});
|
|
|
|
if (role.messaging === undefined) {
|
|
out.messaging = null;
|
|
} else {
|
|
if (!isPlain(role.messaging)) refuse("composition messaging must be a JSON object");
|
|
keysWithin(role.messaging, MESSAGING_KEYS, "messaging");
|
|
out.messaging = {};
|
|
for (const key of MESSAGING_KEYS) {
|
|
out.messaging[key] = stringList(role.messaging[key], `messaging.${key}`);
|
|
out.messaging[key].forEach((entry, i) => {
|
|
if (!ID_PATTERN.test(entry)) refuse(`composition messaging.${key}[${i}] must be a role name matching ${ID_PATTERN}`);
|
|
if (entry === "tester") refuse(`composition messaging.${key}[${i}] names the tester, which is never a role`);
|
|
});
|
|
}
|
|
}
|
|
|
|
out.skills = role.skills === undefined ? [] : stringList(role.skills, "skills");
|
|
out.skills.forEach((name, i) => {
|
|
if (!SKILL_NAME.test(name) || name === "." || name === "..") refuse(`composition skills[${i}] must be a skill directory name matching ${SKILL_NAME}`);
|
|
});
|
|
|
|
out.instructions = text(role.instructions, "instructions");
|
|
|
|
if (role.personality === undefined) {
|
|
out.personality = null;
|
|
} else {
|
|
if (!isPlain(role.personality)) refuse("composition personality must be a JSON object");
|
|
keysWithin(role.personality, PERSONALITY_KEYS, "personality");
|
|
out.personality = {};
|
|
if (role.personality.summary !== undefined) out.personality.summary = text(role.personality.summary, "personality.summary");
|
|
if (role.personality.traits !== undefined) out.personality.traits = stringList(role.personality.traits, "personality.traits");
|
|
if (role.personality.voice !== undefined) out.personality.voice = text(role.personality.voice, "personality.voice");
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function checkGitea(scopes, where) {
|
|
if (!Array.isArray(scopes) || scopes.length === 0) refuse(`composition ${where}.scopes must be a non-empty array of read:<category> or write:<category>`);
|
|
const categories = new Set();
|
|
scopes.forEach((scope, i) => {
|
|
const m = GITEA_SCOPE.exec(scope);
|
|
if (!m || !GITEA_SCOPE_CATEGORIES.includes(m[2])) {
|
|
refuse(`composition ${where}.scopes[${i}] is not a grantable gitea scope (categories: ${GITEA_SCOPE_CATEGORIES.join(", ")}; admin and all are never granted)`);
|
|
}
|
|
if (categories.has(m[2])) refuse(`composition ${where}.scopes[${i}] names the category ${m[2]} again; a token holds one level per category`);
|
|
categories.add(m[2]);
|
|
});
|
|
}
|
|
|
|
function checkVikunja(scopes, where) {
|
|
if (!isPlain(scopes) || Object.keys(scopes).length === 0) refuse(`composition ${where}.scopes must name at least one vikunja route group`);
|
|
Object.keys(scopes).forEach((group, j) => {
|
|
if (!Object.hasOwn(VIKUNJA_GRANTABLE, group)) refuse(`composition ${where}.scopes group ${j + 1} is not a grantable vikunja route group`);
|
|
const verbs = scopes[group];
|
|
if (verbs.length === 0) refuse(`composition ${where}.scopes.${group} must not be empty`);
|
|
verbs.forEach((verb, i) => {
|
|
if (!VIKUNJA_GRANTABLE[group].includes(verb)) refuse(`composition ${where}.scopes.${group}[${i}] is not a grantable vikunja verb`);
|
|
});
|
|
});
|
|
}
|
|
|
|
function checkPluginScopes(scopes, declared, service, where) {
|
|
if (!Array.isArray(scopes) || scopes.length === 0) refuse(`composition ${where}.scopes must be a non-empty array of ${service} scopes`);
|
|
scopes.forEach((scope, i) => {
|
|
if (!declared.includes(scope)) refuse(`composition ${where}.scopes[${i}] is not a scope ${service} declares`);
|
|
});
|
|
}
|
|
|
|
// Step 2: every action, tool, network, service and scope must be in the registry.
|
|
function registered(c, registry) {
|
|
c.tools.forEach((tool, i) => {
|
|
if (!registry.tools.includes(tool)) refuse(`composition tools[${i}] names no registered tool`);
|
|
});
|
|
if (!registry.networks.includes(c.network)) refuse(`composition network must be one of: ${registry.networks.join(", ")}`);
|
|
for (const key of AUTHORITY_KEYS) {
|
|
c.authority[key].forEach((action, i) => {
|
|
if (!registry.actions.includes(action)) refuse(`composition authority.${key}[${i}] names no registered action`);
|
|
});
|
|
}
|
|
const seen = new Set();
|
|
c.credentials.forEach((entry, i) => {
|
|
const where = `credentials[${i}]`;
|
|
if (!Object.hasOwn(registry.services, entry.service)) refuse(`composition ${where}.service names no registered credential service`);
|
|
if (seen.has(entry.service)) refuse(`composition ${where} names ${entry.service} again`);
|
|
seen.add(entry.service);
|
|
const { kind, scopes } = registry.services[entry.service];
|
|
if (kind === HOST_SERVICE) {
|
|
if (entry.scopes !== undefined) refuse(`composition ${where} names ${HOST_SERVICE}, which takes no scopes`);
|
|
} else if (kind === "gitea") {
|
|
checkGitea(entry.scopes, where);
|
|
} else if (kind === "vikunja") {
|
|
checkVikunja(entry.scopes, where);
|
|
} else {
|
|
checkPluginScopes(entry.scopes, scopes, entry.service, where);
|
|
}
|
|
});
|
|
}
|
|
|
|
function eachAction(c, fn) {
|
|
for (const key of AUTHORITY_KEYS) c.authority[key].forEach((action, i) => fn(action, key, i));
|
|
}
|
|
|
|
// Every string in the composition with its path, for step 6.
|
|
function strings(c) {
|
|
const out = [];
|
|
c.tools.forEach((v, i) => out.push([`tools[${i}]`, v]));
|
|
out.push(["network", c.network]);
|
|
eachAction(c, (v, key, i) => out.push([`authority.${key}[${i}]`, v]));
|
|
c.credentials.forEach((entry, i) => {
|
|
out.push([`credentials[${i}].service`, entry.service]);
|
|
if (Array.isArray(entry.scopes)) entry.scopes.forEach((v, j) => out.push([`credentials[${i}].scopes[${j}]`, v]));
|
|
else if (entry.scopes) {
|
|
for (const [group, verbs] of Object.entries(entry.scopes)) {
|
|
out.push([`credentials[${i}].scopes (group key)`, group]);
|
|
verbs.forEach((v, j) => out.push([`credentials[${i}].scopes.${group}[${j}]`, v]));
|
|
}
|
|
}
|
|
});
|
|
if (c.messaging) for (const key of MESSAGING_KEYS) c.messaging[key].forEach((v, i) => out.push([`messaging.${key}[${i}]`, v]));
|
|
c.skills.forEach((v, i) => out.push([`skills[${i}]`, v]));
|
|
out.push(["instructions", c.instructions]);
|
|
if (c.personality) {
|
|
if (c.personality.summary !== undefined) out.push(["personality.summary", c.personality.summary]);
|
|
(c.personality.traits ?? []).forEach((v, i) => out.push([`personality.traits[${i}]`, v]));
|
|
if (c.personality.voice !== undefined) out.push(["personality.voice", c.personality.voice]);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function bounded(value, max, where, { empty = false } = {}) {
|
|
if ((!empty && value.trim().length === 0) || value.length > max) {
|
|
refuse(`composition ${where} must be ${empty ? "" : "non-empty and "}at most ${max} characters`);
|
|
}
|
|
}
|
|
|
|
// Step 6, the bounds half.
|
|
function checkBounds(c) {
|
|
const b = COMPOSITION_BOUNDS;
|
|
bounded(c.instructions, b.instructions, "instructions");
|
|
if (c.personality) {
|
|
if (c.personality.summary !== undefined) bounded(c.personality.summary, b.summary, "personality.summary");
|
|
if (c.personality.voice !== undefined) bounded(c.personality.voice, b.voice, "personality.voice");
|
|
const traits = c.personality.traits ?? [];
|
|
if (traits.length > b.traits) refuse(`composition personality.traits must hold at most ${b.traits} entries`);
|
|
traits.forEach((trait, i) => bounded(trait, b.trait, `personality.traits[${i}]`));
|
|
}
|
|
if (c.messaging) {
|
|
for (const key of MESSAGING_KEYS) {
|
|
if (c.messaging[key].length > b.messaging) refuse(`composition messaging.${key} must hold at most ${b.messaging} entries`);
|
|
}
|
|
}
|
|
if (c.skills.length > b.skills) refuse(`composition skills must hold at most ${b.skills} entries`);
|
|
}
|
|
|
|
// A plain JSON copy of a caller's object, read once. Getters, proxies and toJSON run here and only
|
|
// here, and whatever they throw becomes the fixed message: their error text could carry role text.
|
|
function snapshot(value, message) {
|
|
let copy;
|
|
try {
|
|
copy = isPlain(value) ? JSON.parse(JSON.stringify(value)) : undefined;
|
|
} catch {
|
|
copy = undefined;
|
|
}
|
|
if (!isPlain(copy)) refuse(message);
|
|
return copy;
|
|
}
|
|
|
|
// The chief's resolved role, as loadRole returns it. Read only for step 7; a malformed one refuses.
|
|
function checkChief(chief) {
|
|
const bad = () => refuse("checkComposition needs the chief's resolved role");
|
|
if (!isPlain(chief) || !Array.isArray(chief.tools) || !NETWORKS.includes(chief.network)) bad();
|
|
if (!isPlain(chief.authority) || !AUTHORITY_KEYS.every((k) => Array.isArray(chief.authority[k]))) bad();
|
|
if (!Array.isArray(chief.credentials) || !chief.credentials.every((c) => isPlain(c) && typeof c.service === "string")) bad();
|
|
if (chief.messaging !== null && chief.messaging !== undefined &&
|
|
!(isPlain(chief.messaging) && MESSAGING_KEYS.every((k) => Array.isArray(chief.messaging[k])))) bad();
|
|
return chief;
|
|
}
|
|
|
|
// One string per credential scope a role holds: service, then scope (a vikunja scope is
|
|
// group.verb; host has the one line "host").
|
|
function credentialLines(credentials) {
|
|
const out = [];
|
|
for (const { service, scopes } of credentials) {
|
|
if (scopes === undefined) out.push([service, service]);
|
|
else if (Array.isArray(scopes)) for (const scope of scopes) out.push([service, scope]);
|
|
else for (const [group, verbs] of Object.entries(scopes)) for (const verb of verbs) out.push([service, `${group}.${verb}`]);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Validate a composition (section 4's seven steps, in order) against a registry from
|
|
// buildRegistry, with the chief's resolved role for step 7. options.assertClean is the credential
|
|
// store's check (credentials.assertClean, as the broker's secretCheck); it throws on a held
|
|
// secret. Returns a frozen { composition, flagged }, where each flagged line is
|
|
// { field, value, reasons } and reasons holds "resolve-right" (step 5), "above-chief" (step 7) or
|
|
// both. A composition that fails a step refuses with an OrganizationConfigError.
|
|
export function checkComposition(role, registry, chief, { assertClean } = {}) {
|
|
if (!BUILT.has(registry)) refuse("checkComposition needs a registry from buildRegistry");
|
|
if (typeof assertClean !== "function") refuse("checkComposition needs the credential store's assertClean");
|
|
const boss = checkChief(snapshot(chief, "checkComposition needs the chief's resolved role"));
|
|
|
|
// 1. Unknown keys, and the shape.
|
|
const c = shape(snapshot(role, "composition must be a JSON object"));
|
|
|
|
// 2. Every name in the registry.
|
|
registered(c, registry);
|
|
|
|
// 3. Hard waits stay with the human (R4). From here on an action is a registered name and may be
|
|
// quoted.
|
|
eachAction(c, (action, key, i) => {
|
|
if (!registry.routed.includes(action)) refuse(`composition authority.${key}[${i}] is ${action}, a hard wait the human always decides`);
|
|
});
|
|
|
|
// 4. No hire chains (R9).
|
|
eachAction(c, (action, key, i) => {
|
|
if (NEVER_HIRED.includes(action)) refuse(`composition authority.${key}[${i}] is ${action}; a hired agent cannot hire or fire`);
|
|
});
|
|
|
|
// 5. Every resolve right is a flagged line.
|
|
const lines = [];
|
|
const flag = (field, value, reason) => {
|
|
const line = lines.find((l) => l.field === field && l.value === value);
|
|
if (line) {
|
|
if (!line.reasons.includes(reason)) line.reasons.push(reason);
|
|
} else {
|
|
lines.push({ field, value, reasons: [reason] });
|
|
}
|
|
};
|
|
eachAction(c, (action, key) => {
|
|
if (action.startsWith(RESOLVE_PREFIX)) flag(`authority.${key}`, action, FLAG_RESOLVE);
|
|
});
|
|
|
|
// 6. No secret in any string (R1), and every field within its bound.
|
|
for (const [where, value] of strings(c)) {
|
|
let clean = true;
|
|
try {
|
|
assertClean(value);
|
|
} catch {
|
|
clean = false;
|
|
}
|
|
if (!clean) refuse(`composition ${where} holds a credential`);
|
|
}
|
|
checkBounds(c);
|
|
|
|
// 7. Everything the chief's own role does not hold (R8). A within-role action is held only
|
|
// within-role; a cross-role one either way.
|
|
const chiefWithin = boss.authority.withinRole;
|
|
const chiefAny = [...chiefWithin, ...boss.authority.crossRole];
|
|
c.authority.withinRole.forEach((action) => {
|
|
if (!chiefWithin.includes(action)) flag("authority.withinRole", action, FLAG_ABOVE_CHIEF);
|
|
});
|
|
c.authority.crossRole.forEach((action) => {
|
|
if (!chiefAny.includes(action)) flag("authority.crossRole", action, FLAG_ABOVE_CHIEF);
|
|
});
|
|
c.tools.forEach((tool) => {
|
|
if (!boss.tools.includes(tool)) flag("tools", tool, FLAG_ABOVE_CHIEF);
|
|
});
|
|
if (NETWORKS.indexOf(c.network) > NETWORKS.indexOf(boss.network)) flag("network", c.network, FLAG_ABOVE_CHIEF);
|
|
const held = new Set(credentialLines(boss.credentials).map(([s, v]) => `${s}\n${v}`));
|
|
for (const [service, value] of credentialLines(c.credentials)) {
|
|
if (!held.has(`${service}\n${value}`)) flag(`credentials.${service}`, value, FLAG_ABOVE_CHIEF);
|
|
}
|
|
// Both directions: the broker checks the sender's sendTo and the recipient's receiveFrom. A role
|
|
// with no messaging key may message and hear anyone; "*" stands for that.
|
|
if (boss.messaging) {
|
|
for (const key of MESSAGING_KEYS) {
|
|
const chiefList = boss.messaging[key];
|
|
if (c.messaging === null) flag(`messaging.${key}`, "*", FLAG_ABOVE_CHIEF);
|
|
else c.messaging[key].forEach((target) => {
|
|
if (!chiefList.includes(target)) flag(`messaging.${key}`, target, FLAG_ABOVE_CHIEF);
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const line of lines) line.reasons.sort();
|
|
return deepFreeze({ composition: c, flagged: lines });
|
|
}
|