// 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", () => { for (const domain of ["technical", "delivery"]) { const action = `decision.resolve.${domain}`; const without = { ...CHIEF, authority: { ...CHIEF.authority, withinRole: CHIEF.authority.withinRole.filter(value => value !== action) } }; const input = base({ authority: { withinRole: ["message.send"], crossRole: [action] } }); assert.deepEqual(run(input, CORE, without).flagged, [ { field: "authority.crossRole", value: action, reasons: [FLAG_ABOVE_CHIEF, FLAG_RESOLVE] }, ]); // RR gives the shipped chief both rights. Each still flags, without above-chief. assert.deepEqual(run(input).flagged, [ { field: "authority.crossRole", value: action, reasons: [FLAG_RESOLVE] }, ]); const held = run(base({ authority: { withinRole: [action], crossRole: [] } })); assert.deepEqual(held.flagged, [{ field: "authority.withinRole", value: action, 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"); });