feat(roles): M18 seat-role progressive capability restriction (#45)

Role contracts (roles/<role>.json): roleVersion, name bound to filename,
tools ceiling (subset of pi built-ins), network declared (none|api-only|
open; enforced when network policy lands). Strict schema, fail closed -
a non-role document refuses resolution.

mosaic-task.mjs resolve-role: config-free contract validation, emits
MOSAIC_ROLE_TOOLS / MOSAIC_ROLE_NETWORK.

agent.sh: a declared role binds to its contract. Missing/invalid contract
refuses the launch (exit 2, names the role - the under-equipped-seat
failure mode, mirroring M17 skills). Effective tools = ceiling ∩ requested
(CLI --tools or agent.json caps); no request -> ceiling stands; narrowing
and tool-free outcomes loud on stderr. Adapters unchanged; headless M9
chain (mission ∩ task) untouched.

Ships roles/researcher.json (existing seat declares the role; without the
contract the fail-closed gate would refuse its launch).

Task suite 74 -> 88: contract resolution, wrong-kind/name/network/
duplicate/unsupported/missing refusals, ceiling narrowing E2E (mock
adapter), tool-free E2E, missing-contract refusal. Test-authoring
correction recorded in BUILD-LOG (a check that registered on one path
only, caught by count arithmetic).

Suites 24/88/14/17 + verify green.
This commit is contained in:
2026-09-03 17:25:17 -05:00
parent bf56583a49
commit ca8135d70c
9 changed files with 172 additions and 6 deletions
+35 -1
View File
@@ -245,6 +245,33 @@ function validateTask(document, file) {
};
}
// Role contract (M18): seat-declared role authority. The tools array is a
// ceiling — seats may narrow it, never escalate past it. network is declared
// now and enforced when network policy lands. Strict schema: unknown keys
// refuse, name must match the filename, wrong document kind refuses.
function validateRole(document, file) {
rejectUnknownKeys(document, ["roleVersion", "name", "tools", "network"], "role");
if (document.roleVersion !== 1) fail(2, 'role "roleVersion" must be 1');
validateId(document.name, "role name");
const base = path.basename(file).replace(/\.json$/, "");
if (document.name !== base) fail(2, `role "name" (${document.name}) must match its filename (${base}.json)`);
if (!Array.isArray(document.tools) || document.tools.length === 0) {
fail(2, 'role "tools" must be a non-empty array of tool names');
}
const seen = new Set();
for (const tool of document.tools) {
if (!SUPPORTED_TOOLS.includes(tool)) {
fail(2, `unsupported tool: ${JSON.stringify(tool)} (supported: ${SUPPORTED_TOOLS.join(", ")})`);
}
if (seen.has(tool)) fail(2, `duplicate tool in role tools: ${tool}`);
seen.add(tool);
}
if (document.network !== undefined && !["none", "api-only", "open"].includes(document.network)) {
fail(2, 'role "network" must be one of: none, api-only, open');
}
return { roleVersion: 1, name: document.name, tools: [...seen], network: document.network ?? "none" };
}
function loadConfig() {
const proc = spawnSync(process.execPath, [path.join(PROJECT_ROOT, "scripts", "mosaic-config.mjs"), "validate"], {
cwd: PROJECT_ROOT,
@@ -640,10 +667,17 @@ switch (operation) {
case "prune":
pruneRuns(process.argv.slice(3));
break;
case "resolve-role": {
if (!target) fail(4, "usage: mosaic-task.mjs resolve-role <roleFile>");
const file = path.resolve(target);
const role = validateRole(readJsonFile(file, "role contract"), file);
process.stdout.write(`MOSAIC_ROLE_TOOLS=${role.tools.join(",")}\nMOSAIC_ROLE_NETWORK=${role.network}\n`);
process.exit(0);
}
case "retry":
if (!target) fail(4, "usage: mosaic-task.mjs retry <runId>");
retryRun(target);
break;
default:
fail(4, `unknown operation: ${JSON.stringify(operation ?? "")} (expected validate | run | show | list | retry | prune)`);
fail(4, `unknown operation: ${JSON.stringify(operation ?? "")} (expected validate | run | show | list | retry | prune | resolve-role)`);
}