Compare commits
2
Commits
org-redact2
...
refactor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67d6c9ed84 | ||
|
|
c8dbd3f6ec |
No files matched your search
@@ -0,0 +1,2 @@
|
||||
// Old path of packages/organization/src/composition.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/composition.mjs";
|
||||
@@ -0,0 +1,518 @@
|
||||
// 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.
|
||||
//
|
||||
// Nothing calls checkComposition yet. HIRE_ALLOWED and unhireableAction (vocabulary.mjs) still
|
||||
// bound a hire, and role.mjs and the broker still use them. Slice A4 switches hire over to this
|
||||
// validator and removes them. In P1 the plugin set is whatever the caller passes, empty by
|
||||
// default; nothing here reads a plugin file (PL1 builds install and the manifest).
|
||||
//
|
||||
// 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"]);
|
||||
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 can't hire or fire, whatever HIRE_ALLOWED says later.
|
||||
export const NEVER_HIRED = Object.freeze(["role.hire", "role.fire"]);
|
||||
// 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`);
|
||||
return { name, tools: tools.map(full), actions: actions.map(full), services };
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
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 });
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
// Roles round 2, slice P1 (#1873): the composition validator and its registry.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { loadRole, BusinessError } from "../src/index.mjs";
|
||||
import { GATED_ONLY, HIRE_ALLOWED, unhireableAction, ROUTED } from "../src/vocabulary.mjs";
|
||||
import {
|
||||
buildRegistry, checkComposition, COMPOSITION_BOUNDS, COMPOSITION_KEYS, FLAG_RESOLVE, FLAG_ABOVE_CHIEF,
|
||||
} from "../src/composition.mjs";
|
||||
import { REPO_ROLES } from "./helpers.mjs";
|
||||
|
||||
const SECRET = "sk-test-NOT-A-SECRET-1234";
|
||||
// Stands in for the bus credential store's assertClean. Its own error quotes the secret, so a test
|
||||
// can tell if checkComposition ever passes that error on.
|
||||
const assertClean = (value) => {
|
||||
if (JSON.stringify(value).includes(SECRET)) throw new Error(`credential-leak: ${SECRET}`);
|
||||
};
|
||||
const OPTS = { assertClean };
|
||||
const CHIEF = loadRole(REPO_ROLES, "chief-of-staff");
|
||||
const CORE = buildRegistry();
|
||||
|
||||
function base(overrides = {}) {
|
||||
return {
|
||||
tools: ["read"],
|
||||
network: "none",
|
||||
authority: { withinRole: ["message.send"], crossRole: [] },
|
||||
credentials: [{ service: "gitea", scopes: ["read:repository"] }],
|
||||
instructions: "Do the work in the brief.",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const run = (role, registry = CORE, chief = CHIEF) => checkComposition(role, registry, chief, OPTS);
|
||||
|
||||
function refuses(fn, pattern, ...absent) {
|
||||
assert.throws(fn, (error) => {
|
||||
assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`);
|
||||
assert.equal(error.exitCode, 2);
|
||||
assert.match(error.message, pattern);
|
||||
for (const text of [SECRET, ...absent]) {
|
||||
assert.ok(!error.message.includes(text), `the refusal quotes ${JSON.stringify(text)}: ${error.message}`);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
test("a minimal composition passes with nothing flagged", () => {
|
||||
const { composition, flagged } = run(base());
|
||||
assert.deepEqual(flagged, []);
|
||||
assert.equal(composition.instructions, "Do the work in the brief.");
|
||||
assert.equal(composition.messaging, null);
|
||||
assert.equal(composition.personality, null);
|
||||
assert.ok(Object.isFrozen(composition) && Object.isFrozen(composition.authority.withinRole));
|
||||
});
|
||||
|
||||
test("checkComposition needs a registry from buildRegistry and the store's assertClean", () => {
|
||||
refuses(() => checkComposition(base(), { ...CORE }, CHIEF, OPTS), /registry from buildRegistry/);
|
||||
refuses(() => checkComposition(base(), CORE, CHIEF), /assertClean/);
|
||||
refuses(() => checkComposition(base(), CORE, CHIEF, { assertClean: true }), /assertClean/);
|
||||
refuses(() => checkComposition(base(), CORE, { tools: [] }, OPTS), /chief's resolved role/);
|
||||
});
|
||||
|
||||
// Step 1.
|
||||
test("an unknown key refuses at every level, without quoting the key", () => {
|
||||
const key = `notes ${SECRET}`;
|
||||
refuses(() => run({ ...base(), [key]: 1 }), /role has an unknown key/, key);
|
||||
for (const field of ["name", "title", "contract", "roleVersion", "hireable"]) {
|
||||
refuses(() => run({ ...base(), [field]: "x" }), /role has an unknown key/);
|
||||
}
|
||||
refuses(() => run(base({ authority: { withinRole: [], crossRole: [], [key]: [] } })), /authority has an unknown key/, key);
|
||||
refuses(() => run(base({ credentials: [{ service: "gitea", scopes: ["read:issue"], [key]: 1 }] })), /credentials\[0\] has an unknown key/, key);
|
||||
refuses(() => run(base({ messaging: { sendTo: [], receiveFrom: [], [key]: [] } })), /messaging has an unknown key/, key);
|
||||
refuses(() => run(base({ personality: { summary: "Calm.", [key]: "x" } })), /personality has an unknown key/, key);
|
||||
refuses(() => run(JSON.parse('{"__proto__": {"tools": ["read"]}}')), /unknown key|requires/);
|
||||
});
|
||||
|
||||
test("the required primitives and their shapes", () => {
|
||||
for (const key of ["tools", "network", "authority", "credentials", "instructions"]) {
|
||||
const role = base();
|
||||
delete role[key];
|
||||
refuses(() => run(role), new RegExp(`requires "${key}"`));
|
||||
}
|
||||
refuses(() => run(base({ tools: [] })), /tools must not be empty/);
|
||||
refuses(() => run(base({ tools: ["read", "read"] })), /tools\[1\] repeats/);
|
||||
refuses(() => run(base({ authority: { withinRole: ["message.send"] } })), /authority.crossRole must be an array/);
|
||||
refuses(() => run(base({ authority: { withinRole: ["message.send"], crossRole: ["message.send"] } })), /crossRole\[0\] is also in authority.withinRole/);
|
||||
refuses(() => run(base({ instructions: ["x"] })), /instructions must be a string/);
|
||||
refuses(() => run(base({ instructions: "a\0b" })), /instructions must be a string/);
|
||||
refuses(() => run(base({ messaging: { sendTo: ["tester"], receiveFrom: [] } })), /names the tester/);
|
||||
refuses(() => run(base({ messaging: { sendTo: ["Bad Name"], receiveFrom: [] } })), /sendTo\[0\] must be a role name/, "Bad Name");
|
||||
refuses(() => run(base({ skills: [".."] })), /skills\[0\] must be a skill directory name/);
|
||||
assert.deepEqual(Object.keys(base()).every((k) => COMPOSITION_KEYS.includes(k)), true);
|
||||
});
|
||||
|
||||
// Step 2.
|
||||
test("an unknown action, tool, network, service or scope refuses without quoting it", () => {
|
||||
const odd = `zz-${SECRET}`;
|
||||
refuses(() => run(base({ authority: { withinRole: [odd], crossRole: [] } })), /withinRole\[0\] names no registered action/, odd);
|
||||
refuses(() => run(base({ authority: { withinRole: [], crossRole: ["task.nonesuch"] } })), /crossRole\[0\] names no registered action/, "task.nonesuch");
|
||||
refuses(() => run(base({ tools: ["read", "hammer"] })), /tools\[1\] names no registered tool/, "hammer");
|
||||
refuses(() => run(base({ network: odd })), /network must be one of: none, api-only, open/, odd);
|
||||
refuses(() => run(base({ credentials: [{ service: "jira", scopes: ["read"] }] })), /credentials\[0\].service names no registered/, "jira");
|
||||
refuses(() => run(base({ credentials: [{ service: "gitea", scopes: ["write:nonesuch"] }] })), /scopes\[0\] is not a grantable gitea scope/, "nonesuch");
|
||||
refuses(() => run(base({ credentials: [{ service: "vikunja", scopes: { nonesuch: ["read_one"] } }] })), /group 1 is not a grantable vikunja route group/, "nonesuch");
|
||||
refuses(() => run(base({ credentials: [{ service: "vikunja", scopes: { __proto__: null, ["__proto__"]: ["read_one"] } }] })), /not a grantable vikunja route group/);
|
||||
refuses(() => run(base({ credentials: [{ service: "vikunja", scopes: { tasks: ["obliterate"] } }] })), /scopes.tasks\[0\] is not a grantable vikunja verb/, "obliterate");
|
||||
refuses(() => run(base({ credentials: [{ service: "host", scopes: ["any"] }] })), /host, which takes no scopes/);
|
||||
refuses(() => run(base({ credentials: [{ service: "gitea", scopes: ["read:issue"] }, { service: "gitea", scopes: ["read:user"] }] })), /credentials\[1\] names gitea again/);
|
||||
});
|
||||
|
||||
test("gitea admin and all refuse, at either level", () => {
|
||||
for (const scope of ["admin", "all", "write:admin", "read:admin", "write:all", "read:all", "sudo"]) {
|
||||
refuses(() => run(base({ credentials: [{ service: "gitea", scopes: [scope] }] })), /not a grantable gitea scope/);
|
||||
}
|
||||
refuses(() => run(base({ credentials: [{ service: "gitea", scopes: ["read:issue", "write:issue"] }] })), /scopes\[1\] names the category issue again/);
|
||||
refuses(() => run(base({ credentials: [{ service: "gitea", scopes: [] }] })), /non-empty array/);
|
||||
});
|
||||
|
||||
// Step 3.
|
||||
test("each hard wait refuses, in either authority list", () => {
|
||||
assert.ok(GATED_ONLY.length > 0);
|
||||
for (const action of GATED_ONLY) {
|
||||
refuses(() => run(base({ authority: { withinRole: [action], crossRole: [] } })), /withinRole\[0\] is .* a hard wait/);
|
||||
refuses(() => run(base({ authority: { withinRole: [], crossRole: [action] } })), /crossRole\[0\] is .* a hard wait/);
|
||||
}
|
||||
});
|
||||
|
||||
// Step 4.
|
||||
test("role.hire and role.fire refuse", () => {
|
||||
for (const action of ["role.hire", "role.fire"]) {
|
||||
assert.ok(ROUTED.includes(action), `${action} is routed, so step 4 is what refuses it`);
|
||||
refuses(() => run(base({ authority: { withinRole: [action], crossRole: [] } })), /cannot hire or fire/);
|
||||
refuses(() => run(base({ authority: { withinRole: [], crossRole: [action] } })), /cannot hire or fire/);
|
||||
}
|
||||
});
|
||||
|
||||
// Step 5.
|
||||
test("a decision.resolve action comes back as a flagged line", () => {
|
||||
const { flagged } = run(base({ authority: { withinRole: ["message.send"], crossRole: ["decision.resolve.technical"] } }));
|
||||
assert.deepEqual(flagged, [
|
||||
{ field: "authority.crossRole", value: "decision.resolve.technical", reasons: [FLAG_ABOVE_CHIEF, FLAG_RESOLVE] },
|
||||
]);
|
||||
// Held by the chief: still flagged as a resolve right, and only that.
|
||||
const chief = { ...CHIEF, authority: { withinRole: [...CHIEF.authority.withinRole, "decision.resolve.technical"], crossRole: [] } };
|
||||
const held = run(base({ authority: { withinRole: ["decision.resolve.technical"], crossRole: [] } }), CORE, chief);
|
||||
assert.deepEqual(held.flagged, [{ field: "authority.withinRole", value: "decision.resolve.technical", reasons: [FLAG_RESOLVE] }]);
|
||||
});
|
||||
|
||||
// Step 6.
|
||||
test("a secret in instructions or personality refuses, and the refusal does not contain it", () => {
|
||||
const cases = [
|
||||
[{ instructions: `Use ${SECRET} for the API.` }, /instructions holds a credential/],
|
||||
[{ personality: { summary: `Knows ${SECRET}.` } }, /personality.summary holds a credential/],
|
||||
[{ personality: { traits: ["calm", `key ${SECRET}`] } }, /personality.traits\[1\] holds a credential/],
|
||||
[{ personality: { voice: `Says ${SECRET} often.` } }, /personality.voice holds a credential/],
|
||||
[{ skills: [SECRET] }, /skills\[0\] holds a credential/],
|
||||
];
|
||||
for (const [overrides, pattern] of cases) refuses(() => run(base(overrides)), pattern, "Use ", "Knows", "Says");
|
||||
});
|
||||
|
||||
test("the free text never reaches an error", () => {
|
||||
const prose = "PRIVATE-INSTRUCTION-TEXT";
|
||||
refuses(() => run(base({ instructions: prose.repeat(2000) })), /instructions must be non-empty and at most 16384 characters/, prose);
|
||||
refuses(() => run(base({ instructions: prose, personality: { summary: prose.repeat(20) } })), /personality.summary must be/, prose);
|
||||
refuses(() => run(base({ instructions: prose, authority: { withinRole: ["deploy"], crossRole: [] } })), /hard wait/, prose);
|
||||
});
|
||||
|
||||
test("the length bounds hold, from one exported constant", () => {
|
||||
assert.deepEqual({ ...COMPOSITION_BOUNDS }, { instructions: 16384, summary: 280, traits: 12, trait: 40, voice: 280, messaging: 64, skills: 64 });
|
||||
assert.ok(Object.isFrozen(COMPOSITION_BOUNDS));
|
||||
const b = COMPOSITION_BOUNDS;
|
||||
run(base({ instructions: "x".repeat(b.instructions), personality: { summary: "s".repeat(b.summary), voice: "v".repeat(b.voice), traits: Array.from({ length: b.traits }, (_, i) => `${i}`.padEnd(b.trait, "t")) } }));
|
||||
refuses(() => run(base({ instructions: "x".repeat(b.instructions + 1) })), /instructions must be/);
|
||||
refuses(() => run(base({ instructions: " " })), /instructions must be non-empty/);
|
||||
refuses(() => run(base({ personality: { summary: "s".repeat(b.summary + 1) } })), /personality.summary must be/);
|
||||
refuses(() => run(base({ personality: { voice: "v".repeat(b.voice + 1) } })), /personality.voice must be/);
|
||||
refuses(() => run(base({ personality: { traits: Array.from({ length: b.traits + 1 }, (_, i) => `t${i}`) } })), /at most 12 entries/);
|
||||
refuses(() => run(base({ personality: { traits: ["t".repeat(b.trait + 1)] } })), /traits\[0\] must be/);
|
||||
refuses(() => run(base({ messaging: { sendTo: Array.from({ length: b.messaging + 1 }, (_, i) => `r${i}`), receiveFrom: [] } })), /sendTo must hold at most 64/);
|
||||
refuses(() => run(base({ skills: Array.from({ length: b.skills + 1 }, (_, i) => `s${i}`) })), /skills must hold at most 64/);
|
||||
});
|
||||
|
||||
test("the steps run in order: a hard wait refuses before the secret check", () => {
|
||||
refuses(() => run(base({ instructions: SECRET, authority: { withinRole: ["spend"], crossRole: [] } })), /hard wait/);
|
||||
refuses(() => run(base({ instructions: SECRET, authority: { withinRole: ["role.fire"], crossRole: [] } })), /cannot hire or fire/);
|
||||
});
|
||||
|
||||
// Step 7.
|
||||
test("the shipped coder against the shipped chief flags exactly the hand-built set", () => {
|
||||
const coder = loadRole(REPO_ROLES, "coder");
|
||||
const role = {
|
||||
tools: coder.tools,
|
||||
network: coder.network,
|
||||
authority: coder.authority,
|
||||
credentials: coder.credentials,
|
||||
skills: coder.skills,
|
||||
instructions: readFileSync(join(REPO_ROLES, "coder.md"), "utf8"),
|
||||
};
|
||||
const { flagged } = run(role);
|
||||
const above = (field, value) => ({ field, value, reasons: [FLAG_ABOVE_CHIEF] });
|
||||
const expected = [
|
||||
above("authority.withinRole", "task.update.assigned"),
|
||||
above("authority.withinRole", "git.push.working"),
|
||||
above("authority.withinRole", "review.request"),
|
||||
above("authority.crossRole", "task.scope.change"),
|
||||
above("tools", "write"),
|
||||
above("tools", "edit"),
|
||||
above("tools", "bash"),
|
||||
above("credentials.gitea", "write:issue"),
|
||||
above("credentials.gitea", "write:repository"),
|
||||
above("credentials.vikunja", "tasks.read_one"),
|
||||
above("credentials.vikunja", "tasks.update"),
|
||||
above("credentials.vikunja", "tasks_comments.create"),
|
||||
above("credentials.vikunja", "projects.views_buckets_tasks"),
|
||||
];
|
||||
const key = (l) => `${l.field} ${l.value}`;
|
||||
assert.deepEqual([...flagged].map((l) => ({ ...l, reasons: [...l.reasons] })).sort((a, b) => key(a).localeCompare(key(b))),
|
||||
expected.sort((a, b) => key(a).localeCompare(key(b))));
|
||||
});
|
||||
|
||||
test("network, cross-role and messaging above the chief are flagged", () => {
|
||||
assert.deepEqual(run(base({ network: "open" })).flagged, [{ field: "network", value: "open", reasons: [FLAG_ABOVE_CHIEF] }]);
|
||||
assert.deepEqual(run(base({ network: "api-only" })).flagged, []);
|
||||
// A cross-role action the chief holds within its role is not above it.
|
||||
assert.deepEqual(run(base({ authority: { withinRole: [], crossRole: ["message.send"] } })).flagged, []);
|
||||
// A within-role action the chief holds only cross-role is.
|
||||
const crossChief = { ...CHIEF, authority: { withinRole: ["message.send"], crossRole: ["record.write"] } };
|
||||
assert.deepEqual(run(base({ authority: { withinRole: ["record.write"], crossRole: [] } }), CORE, crossChief).flagged,
|
||||
[{ field: "authority.withinRole", value: "record.write", reasons: [FLAG_ABOVE_CHIEF] }]);
|
||||
const host = run(base({ credentials: [{ service: "host" }] })).flagged;
|
||||
assert.deepEqual(host, [{ field: "credentials.host", value: "host", reasons: [FLAG_ABOVE_CHIEF] }]);
|
||||
const narrow = { ...CHIEF, messaging: { sendTo: ["pm"], receiveFrom: ["qa"] } };
|
||||
assert.deepEqual(run(base(), CORE, narrow).flagged, [
|
||||
{ field: "messaging.sendTo", value: "*", reasons: [FLAG_ABOVE_CHIEF] },
|
||||
{ field: "messaging.receiveFrom", value: "*", reasons: [FLAG_ABOVE_CHIEF] },
|
||||
]);
|
||||
assert.deepEqual(run(base({ messaging: { sendTo: ["pm", "cto"], receiveFrom: ["qa"] } }), CORE, narrow).flagged,
|
||||
[{ field: "messaging.sendTo", value: "cto", reasons: [FLAG_ABOVE_CHIEF] }]);
|
||||
// Review finding 1: a wider inbound list is above the chief too.
|
||||
assert.deepEqual(run(base({ messaging: { sendTo: ["pm"], receiveFrom: ["qa", "cto"] } }), CORE, narrow).flagged,
|
||||
[{ field: "messaging.receiveFrom", value: "cto", reasons: [FLAG_ABOVE_CHIEF] }]);
|
||||
// A chief with no messaging key holds both directions to everyone.
|
||||
assert.deepEqual(run(base({ messaging: { sendTo: ["cto"], receiveFrom: ["cto"] } })).flagged, []);
|
||||
});
|
||||
|
||||
// Review finding 2: the caller's object is read once, as JSON data, and nothing it throws escapes.
|
||||
test("a getter, proxy or toJSON that throws role text refuses with a fixed message", () => {
|
||||
const throwing = base();
|
||||
Object.defineProperty(throwing, "instructions", { enumerable: true, get() { throw new Error(SECRET); } });
|
||||
refuses(() => run(throwing), /^composition must be a JSON object$/);
|
||||
const proxy = new Proxy(base(), { ownKeys() { throw new Error(SECRET); } });
|
||||
refuses(() => run(proxy), /^composition must be a JSON object$/);
|
||||
const proto = new Proxy(base(), { getPrototypeOf() { throw new Error(SECRET); } });
|
||||
refuses(() => run(proto), /^composition must be a JSON object$/);
|
||||
refuses(() => run(base({ personality: { toJSON() { throw new Error(SECRET); } } })), /^composition must be a JSON object$/);
|
||||
const cyclic = base();
|
||||
cyclic.authority.self = cyclic;
|
||||
refuses(() => run(cyclic), /^composition must be a JSON object$/);
|
||||
refuses(() => run(base({ tools: ["read", 10n] })), /^composition must be a JSON object$/);
|
||||
const chief = { ...CHIEF };
|
||||
Object.defineProperty(chief, "tools", { enumerable: true, get() { throw new Error(SECRET); } });
|
||||
refuses(() => run(base(), CORE, chief), /chief's resolved role/);
|
||||
});
|
||||
|
||||
test("a getter that changes its answer is read once: what is checked is what comes back", () => {
|
||||
let reads = 0;
|
||||
const role = base();
|
||||
Object.defineProperty(role, "network", { enumerable: true, get() { reads += 1; return reads === 1 ? "none" : "open"; } });
|
||||
const { composition, flagged } = run(role);
|
||||
assert.equal(reads, 1);
|
||||
assert.equal(composition.network, "none");
|
||||
assert.deepEqual(flagged, []);
|
||||
});
|
||||
|
||||
// The plugin-declared set (section 7, registry shape only).
|
||||
test("a plugin action is accepted only with its prefix", () => {
|
||||
const registry = buildRegistry({ plugins: [{ name: "forge", tools: ["lathe"], actions: ["build.run"], services: [{ name: "store", scopes: ["read", "write"] }] }] });
|
||||
const ok = run(base({
|
||||
tools: ["read", "forge.lathe"],
|
||||
authority: { withinRole: ["forge.build.run"], crossRole: [] },
|
||||
credentials: [{ service: "forge.store", scopes: ["read"] }],
|
||||
}), registry);
|
||||
assert.deepEqual(ok.flagged.map((l) => `${l.field} ${l.value}`).sort(),
|
||||
["authority.withinRole forge.build.run", "credentials.forge.store read", "tools forge.lathe"]);
|
||||
refuses(() => run(base({ authority: { withinRole: ["build.run"], crossRole: [] } }), registry), /names no registered action/, "build.run");
|
||||
refuses(() => run(base({ tools: ["lathe"] }), registry), /names no registered tool/, "lathe");
|
||||
refuses(() => run(base({ credentials: [{ service: "store", scopes: ["read"] }] }), registry), /names no registered credential service/);
|
||||
refuses(() => run(base({ credentials: [{ service: "forge.store", scopes: ["admin"] }] }), registry), /is not a scope forge.store declares/, "admin");
|
||||
// Without the plugin in the registry, its names are unknown.
|
||||
refuses(() => run(base({ authority: { withinRole: ["forge.build.run"], crossRole: [] } })), /names no registered action/);
|
||||
});
|
||||
|
||||
test("a plugin refuses if it reuses a core name or declares a hard wait", () => {
|
||||
const reg = (plugin) => () => buildRegistry({ plugins: [plugin] });
|
||||
for (const action of GATED_ONLY) refuses(reg({ name: "forge", actions: [action] }), /a hard wait/);
|
||||
refuses(reg({ name: "forge", actions: ["message.send"] }), /reuses the core name "message.send"/);
|
||||
refuses(reg({ name: "forge", tools: ["read"] }), /reuses the core name "read"/);
|
||||
refuses(reg({ name: "forge", services: [{ name: "gitea", scopes: ["x"] }] }), /reuses the core name "gitea"/);
|
||||
for (const name of ["git", "role", "decision", "read", "gitea", "host", "open"]) {
|
||||
refuses(reg({ name, actions: ["x"] }), /is a core name/);
|
||||
}
|
||||
refuses(reg({ name: "Forge" }), /name must match/);
|
||||
refuses(reg({ name: "forge", extra: 1 }), /has a key outside/);
|
||||
refuses(reg({ name: "forge", actions: ["a", "a"] }), /repeats an earlier entry/);
|
||||
refuses(reg({ name: "forge", tools: ["a"], actions: ["a"] }), /declares "forge.a" twice/);
|
||||
refuses(reg({ name: "forge", actions: [`x ${SECRET}`] }), /must be a name matching/);
|
||||
refuses(reg({ name: "forge", services: [{ name: "store", scopes: [] }] }), /scopes must not be empty/);
|
||||
refuses(() => buildRegistry({ plugins: [{ name: "forge" }, { name: "forge" }] }), /plugin "forge" twice/);
|
||||
refuses(() => buildRegistry({ plugins: {} }), /plugins must be an array/);
|
||||
// A plugin action is routed, so it never reads as a hard wait at step 3.
|
||||
assert.ok(buildRegistry({ plugins: [{ name: "forge", actions: ["build.run"] }] }).routed.includes("forge.build.run"));
|
||||
});
|
||||
|
||||
test("HIRE_ALLOWED and unhireableAction stay until A4", () => {
|
||||
assert.ok(Array.isArray(HIRE_ALLOWED) && HIRE_ALLOWED.length > 0);
|
||||
assert.equal(typeof unhireableAction, "function");
|
||||
});
|
||||
Reference in new issue
Block a user