// Row 149 (#1643, REQ-ROLE-5): the security-reviewer and merge-gate roles, and the proactive line in // every role contract. security-reviewer holds read-only tools and the verdict; merge-gate holds // git.merge.protected cross-role and nothing else, so every landing needs a decision it raised. // A widened file fails here. Fixtures live in a temp directory and nothing reads the real config. import { test } from "node:test"; import assert from "node:assert/strict"; import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { ACTIONS, BusinessError, GATED_ONLY, classify, loadBusiness, loadRole, resolveInstance } from "../src/index.mjs"; import { businessDoc, REPO_ROLES, systemFor, tmp, writeJson } from "./helpers.mjs"; const APPROVED = Object.freeze({ "security-reviewer": { tools: ["read", "grep", "find", "ls"], network: "api-only", authority: { withinRole: ["review.verdict"], crossRole: [] }, credentials: [], // The verdict goes back to the role that asked; coder and cto hold review.request. messaging: { sendTo: ["human", "chief-of-staff", "pm", "cto", "coder"], receiveFrom: ["human", "chief-of-staff", "pm", "cto", "coder"], }, }, "merge-gate": { tools: ["read", "grep", "find", "ls"], network: "api-only", authority: { withinRole: [], crossRole: ["git.merge.protected"] }, credentials: [], // cto, the technical arbiter in businessDoc, closes merge-gate's raise and so must reach it (row 133). messaging: { sendTo: ["human", "chief-of-staff", "pm", "cto"], receiveFrom: ["human", "chief-of-staff", "pm", "cto"], }, }, }); const NAMES = Object.keys(APPROVED); const WRITES = ["write", "edit", "bash", "web"]; const PROACTIVE = /Within\s+your\s+grants,\s+act\s+on\s+what\s+you\s+see\s+without\s+waiting\s+to\s+be\s+asked,\s+and\s+raise\s+a\s+decision\s+or\s+send\s+a\s+message\s+for\s+anything\s+outside\s+them\./; function fixture(mutate = () => {}) { const root = tmp(); const doc = businessDoc(root, "acme"); for (const name of NAMES) doc.roles[name] = { definition: name }; mutate(doc); writeJson(join(root, "businesses", "acme.json"), doc); return { root, business: loadBusiness("acme", { dir: root, rolesDir: REPO_ROLES }) }; } const resolved = ({ root, business }, instance) => resolveInstance({ system: systemFor(root), business, instance }); test("both definitions load, with a contract file, and are exactly the approved lists", () => { for (const name of NAMES) { const role = loadRole(REPO_ROLES, name); assert.equal(role.roleVersion, 2); assert.equal(role.contract, `${name}.md`); assert.ok(existsSync(join(REPO_ROLES, `${name}.md`)), name); const { title, ...rest } = JSON.parse(readFileSync(join(REPO_ROLES, `${name}.json`), "utf8")); assert.equal(typeof title, "string"); assert.deepEqual(rest, { roleVersion: 2, name, contract: `${name}.md`, ...APPROVED[name] }, name); assert.match(readFileSync(join(REPO_ROLES, `${name}.md`), "utf8"), new RegExp(`^# Role contract: ${name}\\n`), name); } }); test("security-reviewer has no write or shell tool and classifies only the verdict as its own", () => { const role = loadRole(REPO_ROLES, "security-reviewer"); for (const tool of WRITES) assert.ok(!role.tools.includes(tool), tool); const record = resolved(fixture(), "security-reviewer"); for (const tool of WRITES) assert.ok(!record.limits.tools.includes(tool), tool); assert.deepEqual(record.credentials, {}); for (const action of ACTIONS) assert.equal(classify(record, action), action === "review.verdict" ? "within" : "gated", action); }); test("merge-gate's only grant is git.merge.protected cross-role, and it has no write tool", () => { const role = loadRole(REPO_ROLES, "merge-gate"); assert.deepEqual(role.authority, { withinRole: [], crossRole: ["git.merge.protected"] }); for (const tool of WRITES) assert.ok(!role.tools.includes(tool), tool); const record = resolved(fixture(), "merge-gate"); assert.deepEqual(record.credentials, {}); for (const action of ACTIONS) assert.equal(classify(record, action), action === "git.merge.protected" ? "cross" : "gated", action); }); test("neither role can be an arbiter: its messaging lists refuse the business file (row 133)", () => { for (const name of NAMES) { for (const kind of ["technical", "delivery"]) { assert.throws(() => fixture((doc) => (doc.arbiters[kind] = name)), (e) => e instanceof BusinessError && e.message.includes(`arbiters.${kind} names ${name}, whose role definition ${name} declares messaging lists`), `${kind} ${name}`); } } }); test("every role contract carries the proactive line in its Authority section", () => { const contracts = readdirSync(REPO_ROLES).filter((f) => f.endsWith(".md")); for (const required of ["assistant.md", "coder.md", "reviewer.md", "pm.md", "cto.md", "chief-of-staff.md", "security-reviewer.md", "merge-gate.md"]) { assert.ok(contracts.includes(required), required); } for (const file of contracts) { const text = readFileSync(join(REPO_ROLES, file), "utf8"); const start = text.indexOf("\n## Authority\n"); assert.ok(start >= 0, file); const end = text.indexOf("\n## ", start + 1); assert.match(text.slice(start, end === -1 ? undefined : end), PROACTIVE, file); } }); test("pm and chief-of-staff say where a raise goes under REQ-DEC-2", () => { for (const name of ["pm", "chief-of-staff"]) { const text = readFileSync(join(REPO_ROLES, `${name}.md`), "utf8").replace(/\s+/g, " "); assert.match(text, /a cross-role raise, or a gated raise that cites the project's current spec commit, goes to the business's arbiter for its domain/, name); assert.match(text, /Pass `domain` to `raise_decision`, `technical` or `delivery`; leaving it out means `delivery`/, name); assert.match(text, /An uncited gated raise, and every hard wait, goes to the human through the inbox/, name); assert.doesNotMatch(text, /raise a decision for the human/, name); // The hard waits named are exactly GATED_ONLY, so a vocabulary change shows up here. const listed = text.match(/The hard waits are (.*?); a citation never moves them/)[1].match(/`[a-z.]+`/g).map((w) => w.slice(1, -1)); assert.deepEqual(listed.sort(), [...GATED_ONLY].sort(), name); // Q169 (broker.mjs END_PROTECTED, #hardWait): revoking the assistant is a hard wait as well. assert.match(text, /A `role\.revoke` raise whose target is the assistant is a hard wait too: it goes to the human, cited or not\./, name); } }); // Sage's ruling sage-0684 on Filbert's 28878 item 1: the launcher binds land_work once, at launch, when // the broker's landHeld is true, so the run that raises the merge decision can't land it. A later run // of the same instance, launched after the approval, lands. The cto asks for that run and the chief // of staff launches it. The broker and launcher states are row 137's tests (BUILD.md, round 3). // The raise cites the spec commit for acceptance 314 steps 5 and 8 (sage-0691). test("the merge landing takes two runs: merge-gate, cto and chief-of-staff each say their part", () => { const read = (name) => readFileSync(join(REPO_ROLES, `${name}.md`), "utf8").replace(/\s+/g, " "); const gate = read("merge-gate"); assert.match(gate, /Cite the project's current spec commit on that raise: read it with `read_spec`, then pass `project`, `spec_path` `spec\/north-star\.md` and `spec_commit`\./); assert.match(gate, /If the raise is refused `stale-spec`, read the spec again and raise once more with the current commit\./); assert.match(gate, /If the project has no spec the human saved \(`read_spec` is refused, or `confirmed` is false\), raise uncited and say so in the question\./); assert.match(gate, /The run that raises the decision ends its turn there\./); assert.match(gate, /If the approval arrives in a run whose tools have no `land_work`, don't try to land and don't raise again\./); assert.match(gate, /A run launched after the approval has `land_work`, and its launch record has a "Landing" line\./); // Filbert 28913 (sage-0707): the later run's turn comes on the chief of staff's message, since the raising run read the DECISION. assert.match(gate, /Its turn comes on the chief of staff's message naming the task reference and the decision id: call `land_work` with them\./); // sage-0709: only recordLand spends a decision (broker.mjs #spent). checkLand refuses decision-consumed after // it; a land started while another is in flight is refused at recordLand, in the later RESULT. assert.match(gate, /A `decision-consumed` refusal, in the `land_work` reply or in the later RESULT, means the landing already happened: another landing of the decision got there first\./); assert.match(gate, /Report the task landed in your answer, and don't land or raise again\./); assert.doesNotMatch(gate, /Once the decision is resolved with its approval option, call `land_work`/); assert.match(read("cto"), /When you resolve a `git\.merge\.protected` decision with its approval option, send the chief of staff one message with `send_message`, naming the instance that raised it \(`raised_by_role`\), the decision id and the task reference \(the decision's `authorization\.target`\)\./); const chief = read("chief-of-staff"); assert.match(chief, /When the cto's message names an instance whose `git\.merge\.protected` decision it approved, the decision id and the task reference, launch that instance with `launch`\./); assert.match(chief, /Once `launch` for that instance succeeds, the first time or after an end, send the instance one message with `send_message` naming the task reference and the decision id\./); assert.match(chief, /A run takes a turn only on a message it receives, and the approval went to the run that raised it, so the new run lands on your message\./); // sage-0691: the chief of staff ends a run only after launch refused instance-running, and only that instance's. assert.match(chief, /End a run only when all of these hold: the cto's message named the instance and the decision, `launch` for that instance was refused `instance-running`, and the run belongs to that instance\./); assert.match(chief, /Then end it with `end_run`, with a reason naming the decision, and launch once it has exited\. End no other run\./); assert.match(chief, /Only the human stops a run, with `mosaic stop`, apart from the merge relaunch under Duties\./); });