Files

327 lines
20 KiB
JavaScript

// 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, 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("A4 removes the legacy hire ceiling", async () => {
const vocabulary=await import("../src/vocabulary.mjs");
assert.equal(Object.hasOwn(vocabulary,"HIRE_ALLOWED"),false);
assert.equal(Object.hasOwn(vocabulary,"unhireableAction"),false);
});