import { test } from "node:test"; import assert from "node:assert/strict"; import { existsSync, readdirSync, symlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { loadRole, loadRoleFile, validateRoleDocument, ACTIONS, GATED_ONLY, BusinessError } from "../src/index.mjs"; import { REPO_ROLES, tmp, writeJson } from "./helpers.mjs"; import { CREATIVE_DEFINITIONS } from "../src/vocabulary.mjs"; function refuses(fn, pattern, exitCode = 2) { assert.throws(fn, (error) => { assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`); assert.equal(error.exitCode, exitCode); assert.match(error.message, pattern); return true; }); } function scratchRole(overrides = {}, contract = "# contract\n\n## Authority\n") { const dir = tmp(); if (contract !== null) writeFileSync(join(dir, "r.md"), contract); const doc = { roleVersion: 2, name: "r", title: "R", contract: "r.md", tools: ["read"], network: "none", authority: { withinRole: ["message.send"], crossRole: [] }, credentials: [{ service: "gitea", scopes: ["read:issue"] }], ...overrides, }; return { dir, file: join(dir, "r.json"), doc }; } const check = (overrides, contract) => { const { file, doc } = scratchRole(overrides, contract); return validateRoleDocument(doc, file); }; test("the four shipped version 2 roles load", () => { for (const name of ["pm", "cto", "coder", "reviewer"]) { const role = loadRole(REPO_ROLES, name); assert.equal(role.roleVersion, 2); assert.equal(role.contractPath, join(REPO_ROLES, `${name}.md`)); assert.deepEqual(role.credentials.map((c) => c.service), ["gitea", "vikunja"]); for (const action of [...role.authority.withinRole, ...role.authority.crossRole]) { assert.ok(!GATED_ONLY.includes(action), `${name} lists gated ${action}`); } assert.ok(Object.isFrozen(role.authority.withinRole)); } }); test("shipped role scopes match addendum B section 2 and the SR runbook", () => { const scopes = (name, service) => loadRole(REPO_ROLES, name).credentials.find((c) => c.service === service).scopes; assert.deepEqual(scopes("pm", "vikunja"), { tasks: ["read_one", "create", "update"], tasks_assignees: ["create", "delete"], tasks_relations: ["create", "delete"], tasks_labels: ["create", "delete"], tasks_comments: ["create"], labels: ["read_all"], projects: ["views_buckets_tasks"], }); for (const worker of ["cto", "coder", "reviewer"]) { assert.deepEqual(scopes(worker, "vikunja"), { tasks: ["read_one", "update"], tasks_comments: ["create"], projects: ["views_buckets_tasks"] }); assert.deepEqual(scopes(worker, "gitea"), ["write:issue", "write:repository", "read:user"]); } assert.deepEqual(scopes("pm", "gitea"), ["write:issue", "read:repository", "read:user"]); }); test("shipped authority follows the note's table", () => { const auth = (name) => loadRole(REPO_ROLES, name).authority; assert.ok(auth("coder").withinRole.includes("git.push.working")); // Assignee is pm-only (decision 90): a coder grant of task.reassign could never succeed. assert.deepEqual(auth("coder").crossRole, ["task.scope.change"]); assert.ok(!auth("reviewer").withinRole.includes("git.push.working")); assert.deepEqual(auth("reviewer").crossRole, []); assert.ok(auth("chief-of-staff").withinRole.includes("role.launch")); assert.ok(!auth("pm").withinRole.includes("role.launch")); // Row 141 (REQ-MSG-2): the chief alone of the shipped roles sets instance names. assert.ok(auth("chief-of-staff").withinRole.includes("instance.name")); const others = readdirSync(REPO_ROLES).filter((f) => f.endsWith(".json") && !["conductor-policy.json", "chief-of-staff.json"].includes(f)); assert.ok(others.length >= 10, String(others.length)); for (const name of others.map((f) => f.slice(0, -5))) { assert.ok(!auth(name).withinRole.includes("instance.name"), name); assert.ok(!auth(name).crossRole.includes("instance.name"), name); // Row 194 (PRD 1.7 REQ-COS-3, sage-1471): hire and fire are the chief's alone too. for (const action of ["role.hire", "role.fire"]) { assert.ok(!auth(name).withinRole.includes(action), `${name} ${action}`); assert.ok(!auth(name).crossRole.includes(action), `${name} ${action}`); } } assert.ok(auth("chief-of-staff").withinRole.includes("role.hire") && auth("chief-of-staff").withinRole.includes("role.fire")); assert.ok(auth("cto").withinRole.includes("decision.resolve.technical")); assert.ok(!auth("pm").withinRole.includes("decision.resolve.technical")); }); test("every role file in roles/ loads through the validator, and a v2 contract exists", () => { // conductor-policy.json is the conductor's policy, not a role definition. const names = readdirSync(REPO_ROLES).filter((f) => f.endsWith(".json") && f !== "conductor-policy.json").map((f) => f.slice(0, -5)); for (const required of ["assistant", "chief-of-staff", "pm"]) assert.ok(names.includes(required), required); for (const name of names) { const role = loadRole(REPO_ROLES, name); assert.equal(role.name, name); if (role.roleVersion === 2) assert.ok(existsSync(role.contractPath), `${name} contract`); } }); test("version 1 files keep loading with no authority", () => { const role = loadRole(REPO_ROLES, "researcher"); assert.equal(role.roleVersion, 1); assert.equal(role.contractPath, null); assert.deepEqual(role.authority, { withinRole: [], crossRole: [] }); assert.equal(role.messaging, null); assert.deepEqual(role.tools, ["read", "grep", "find", "ls", "bash"]); const dir = tmp(); const file = writeJson(join(dir, "plain.json"), { roleVersion: 1, name: "plain", tools: ["read"] }); assert.equal(loadRoleFile(file).network, "none"); refuses(() => loadRoleFile(writeJson(join(dir, "v1x.json"), { roleVersion: 1, name: "v1x", tools: ["read"], authority: {} })), /unsupported role key: "authority"/); }); test("the conductor policy isn't a role", () => { refuses(() => loadRole(REPO_ROLES, "conductor-policy"), /roleVersion/); }); test("a missing role file is exit 4, a symbolic link too", () => { const dir = tmp(); refuses(() => loadRole(dir, "absent"), /not found/, 4); writeJson(join(dir, "real.json"), { roleVersion: 1, name: "link", tools: ["read"] }); symlinkSync("real.json", join(dir, "link.json")); refuses(() => loadRole(dir, "link"), /non-symbolic-link/, 4); refuses(() => loadRole(dir, "../etc"), /role name/); }); test("version 2 refusals", () => { assert.equal(check({}).name, "r"); refuses(() => check({ roleVersion: 3 }), /roleVersion/); refuses(() => check({ extra: 1 }), /unsupported role key: "extra"/); refuses(() => check({ name: "other" }), /must match its filename/); refuses(() => check({ title: undefined }), /requires "title"/); refuses(() => check({ title: "x".repeat(81) }), /title/); refuses(() => check({ network: "everywhere" }), /network/); refuses(() => check({ tools: [] }), /tools/); refuses(() => check({ tools: ["read", "read"] }), /duplicate/); refuses(() => check({ tools: ["render3d"] }), /unsupported tool/); // REQ-HARN-2: "web" is the grant for the broker's web tools. assert.deepEqual(check({ tools: ["read", "web"] }).tools, ["read", "web"]); }); test("authority: closed vocabulary, no gated-only action, no overlap", () => { refuses(() => check({ authority: { withinRole: ["task.delete"], crossRole: [] } }), /unknown action/); for (const gated of GATED_ONLY) { refuses(() => check({ authority: { withinRole: [gated], crossRole: [] } }), /always gated/); refuses(() => check({ authority: { withinRole: [], crossRole: [gated] } }), /always gated/); } refuses(() => check({ authority: { withinRole: ["task.reassign"], crossRole: ["task.reassign"] } }), /both withinRole and crossRole/); refuses(() => check({ authority: { withinRole: [] } }), /crossRole must be an array/); refuses(() => check({ authority: { withinRole: [], crossRole: [], gated: [] } }), /unsupported role "authority" key/); }); test("messaging: optional, role definitions and human, never the tester", () => { assert.equal(check({}).messaging, null); const role = check({ messaging: { sendTo: ["human", "chief-of-staff"], receiveFrom: [] } }); assert.deepEqual(role.messaging, { sendTo: ["human", "chief-of-staff"], receiveFrom: [] }); assert.ok(Object.isFrozen(role.messaging.sendTo)); refuses(() => check({ messaging: "human" }), /role "messaging" must be a JSON object/); refuses(() => check({ messaging: { sendTo: [] } }), /messaging\.receiveFrom must be an array/); refuses(() => check({ messaging: { receiveFrom: [] } }), /messaging\.sendTo must be an array/); refuses(() => check({ messaging: { sendTo: [], receiveFrom: [], extra: [] } }), /unsupported role "messaging" key/); refuses(() => check({ messaging: { sendTo: ["pm", "pm"], receiveFrom: [] } }), /duplicate entry in role messaging\.sendTo/); refuses(() => check({ messaging: { sendTo: [], receiveFrom: ["not a role"] } }), /messaging\.receiveFrom entry/); refuses(() => check({ messaging: { sendTo: [7], receiveFrom: [] } }), /messaging\.sendTo entry/); refuses(() => check({ messaging: { sendTo: ["tester"], receiveFrom: [] } }), /messaging\.sendTo lists tester/); refuses(() => check({ messaging: { sendTo: [], receiveFrom: ["tester"] } }), /messaging\.receiveFrom lists tester/); // Version 1 has no messaging key. const file = writeJson(join(tmp(), "v1m.json"), { roleVersion: 1, name: "v1m", tools: ["read"], messaging: { sendTo: [], receiveFrom: [] } }); refuses(() => loadRoleFile(file), /unsupported role key: "messaging"/); }); test("row 64 skills: optional, distinct skill directory names, none on version 1", () => { assert.deepEqual(check({}).skills, []); const role = check({ skills: ["ms-unslop", "ms_sdlc.v2"] }); assert.deepEqual(role.skills, ["ms-unslop", "ms_sdlc.v2"]); assert.ok(Object.isFrozen(role.skills)); refuses(() => check({ skills: "ms-unslop" }), /role skills must be an array/); refuses(() => check({ skills: ["ms-unslop", "ms-unslop"] }), /duplicate entry in role skills/); for (const bad of [".", "..", "", "a/b", "../ms-unslop", "ms unslop", 7, null]) { refuses(() => check({ skills: [bad] }), /role skill must be a skill directory name/); } const file = writeJson(join(tmp(), "v1s.json"), { roleVersion: 1, name: "v1s", tools: ["read"], skills: ["ms-unslop"] }); refuses(() => loadRoleFile(file), /unsupported role key: "skills"/); assert.deepEqual(validateRoleDocument({ roleVersion: 1, name: "v1s", tools: ["read"] }, file).skills, []); }); test("credentials: Gitea scopes", () => { const gitea = (scopes) => check({ credentials: [{ service: "gitea", scopes }] }); assert.deepEqual(gitea(["write:repository", "read:user"]).credentials[0].scopes, ["write:repository", "read:user"]); refuses(() => gitea(["write:admin"]), /unsupported gitea scope/); refuses(() => gitea(["all"]), /unsupported gitea scope/); refuses(() => gitea(["sudo:repository"]), /unsupported gitea scope/); refuses(() => gitea(["read:issue", "write:issue"]), /name issue twice/); refuses(() => gitea([]), /must not be empty/); }); test("credentials: Vikunja scopes are a group-to-verbs map from the grantable list", () => { const vikunja = (scopes) => check({ credentials: [{ service: "vikunja", scopes }] }); assert.deepEqual(vikunja({ tasks: ["read_one"] }).credentials[0].scopes, { tasks: ["read_one"] }); refuses(() => vikunja({ tasks: ["delete"] }), /not grantable/); refuses(() => vikunja({ projects: ["create"] }), /not grantable/); refuses(() => vikunja({ tokens: ["read_all"] }), /route group not grantable/); refuses(() => vikunja({ projects_webhooks: ["create"] }), /route group not grantable/); refuses(() => vikunja({ tasks: [] }), /must not be empty/); refuses(() => vikunja({}), /at least one route group/); refuses(() => vikunja(["tasks.read"]), /JSON object/); }); test("credentials: services", () => { refuses(() => check({ credentials: [{ service: "github", scopes: [] }] }), /service must be one of/); refuses(() => check({ credentials: [{ service: "gitea", scopes: ["read:issue"] }, { service: "gitea", scopes: ["read:user"] }] }), /twice/); refuses(() => check({ credentials: [{ service: "gitea", scopes: ["read:issue"], token: "x" }] }), /unsupported role credentials\[0\] key/); assert.deepEqual(check({ credentials: [] }).credentials, []); }); test("contract: a non-empty regular Markdown file beside the role file", () => { refuses(() => check({}, null), /contract not found/); refuses(() => check({}, ""), /non-empty regular file/); refuses(() => check({ contract: "../r.md" }), /Markdown file name/); refuses(() => check({ contract: "roles/r.md" }), /Markdown file name/); refuses(() => check({ contract: "r.txt" }), /Markdown file name/); const { dir, file, doc } = scratchRole({ contract: "link.md" }); symlinkSync("r.md", join(dir, "link.md")); refuses(() => validateRoleDocument(doc, file), /non-empty regular file/); }); // Row 213 (C2-08, #1714, M1): a creative role reads and answers the human, and nothing else. const CREATIVE = { companion: ["read", "web"], ideas: ["read"], research: ["read", "web"], proofreader: ["read"], "fact-checker": ["read"], continuity: ["read"] }; // Row 285 (C2-08b, #1790, mos-1309 item 5): each also holds library.write and the gitea credential, whose // scopes equal what the Gitea grant path mints (gitea-grant.mjs SEAT_SCOPES, sage-2915). test("the six creative roles load: read tools, message.send and library.write, gitea, messaging with the human only", () => { for (const [name, tools] of Object.entries(CREATIVE)) { const role = loadRole(REPO_ROLES, name); assert.equal(role.roleVersion, 2, name); assert.deepEqual(role.tools, tools, name); assert.equal(role.network, tools.includes("web") ? "api-only" : "none", name); assert.deepEqual(role.authority, { withinRole: ["message.send", "library.write"], crossRole: [] }, name); assert.deepEqual(role.credentials, [{ service: "gitea", scopes: ["read:user", "write:repository"] }], name); assert.deepEqual(role.messaging, { sendTo: ["human"], receiveFrom: ["human"] }, name); } }); test("M1: a creative role file wider than read and answer is refused", () => { const creative = (name, overrides) => { const dir = tmp(); writeFileSync(join(dir, `${name}.md`), "# contract\n\n## Authority\n"); const doc = { roleVersion: 2, name, title: "C", contract: `${name}.md`, tools: ["read"], network: "none", authority: { withinRole: ["message.send"], crossRole: [] }, credentials: [], messaging: { sendTo: ["human"], receiveFrom: ["human"] }, ...overrides, }; // An override of undefined drops the key. for (const key of Object.keys(doc)) if (doc[key] === undefined) delete doc[key]; return () => validateRoleDocument(doc, join(dir, `${name}.json`)); }; assert.doesNotThrow(creative("ideas", { tools: ["read", "grep", "find", "ls", "web"] })); for (const tool of ["write", "edit", "bash"]) { refuses(creative("ideas", { tools: ["read", tool] }), /^creative role ideas may not name tools (?:write|edit|bash) \(allowed: /); } const gitea = [{ service: "gitea", scopes: ["read:user", "write:repository"] }]; assert.doesNotThrow(creative("ideas", { authority: { withinRole: ["message.send", "library.write"], crossRole: [] }, credentials: gitea })); // Row 285: any other action, at either class, refuses, each routed action looped from the list. const only = /^creative role proofreader may hold only withinRole message\.send and library\.write$/; for (const action of ACTIONS.filter((a) => !GATED_ONLY.includes(a) && !["message.send", "library.write"].includes(a))) { refuses(creative("proofreader", { authority: { withinRole: ["message.send", "library.write", action], crossRole: [] } }), only); } refuses(creative("proofreader", { authority: { withinRole: [], crossRole: ["message.send"] } }), only); refuses(creative("proofreader", { authority: { withinRole: ["message.send"], crossRole: ["library.write"] } }), only); // Row 285: library.write with any credential but gitea refuses. refuses(creative("ideas", { authority: { withinRole: ["message.send", "library.write"], crossRole: [] }, credentials: [...gitea, { service: "vikunja", scopes: { tasks: ["read_one"] } }] }), /^creative role ideas may hold only the gitea credential \(got vikunja\)$/); refuses(creative("ideas", { authority: { withinRole: ["message.send", "library.write"], crossRole: [] }, credentials: [{ service: "host" }] }), /^creative role ideas may hold only the gitea credential \(got host\)$/); refuses(creative("continuity", { messaging: undefined }), /^creative role continuity requires "messaging"$/); refuses(creative("research", { messaging: { sendTo: ["human", "pm"], receiveFrom: ["human"] } }), /^creative role research messaging.sendTo may name only human$/); refuses(creative("research", { messaging: { sendTo: ["human"], receiveFrom: ["assistant"] } }), /^creative role research messaging.receiveFrom may name only human$/); refuses(creative("companion", { roleVersion: 1, title: undefined, contract: undefined, authority: undefined, credentials: undefined, messaging: undefined }), /^creative role companion must be version 2$/); }); test("A4 removes the hireable key from shipped and custom roles", () => { assert.equal(Object.hasOwn(loadRole(REPO_ROLES,"chief-of-staff"),"hireable"),false); refuses(() => check({hireable:[]}),/unsupported role key/); });