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:
@@ -109,6 +109,29 @@ export MOSAIC_AGENT_NAME="$NAME"
|
||||
[ -n "$ROLE" ] && export MOSAIC_AGENT_ROLE="$ROLE"
|
||||
export MOSAIC_INTERACTIVE=1
|
||||
if [ -z "$TOOLS" ] && [ -n "$DEFCAPS" ]; then TOOLS="$DEFCAPS"; fi
|
||||
|
||||
# Role ceiling (M18): a declared role binds to roles/<role>.json; its tools
|
||||
# are a ceiling that the seat definition or CLI may narrow, never escalate
|
||||
# past. A missing or invalid contract refuses the launch - a declared role
|
||||
# that resolves to nothing is the under-equipped-seat failure mode.
|
||||
if [ -n "$ROLE" ]; then
|
||||
ROLES_DIR="${MOSAIC_ROLES_DIR:-roles}"
|
||||
ROLE_FILE="$ROLES_DIR/$ROLE.json"
|
||||
[ -r "$ROLE_FILE" ] || { echo "agent: role '$ROLE' is declared but has no contract: $ROLE_FILE" >&2; exit 2; }
|
||||
ROLE_OUT="$(node scripts/mosaic-task.mjs resolve-role "$ROLE_FILE")" || { echo "agent: invalid role contract: $ROLE_FILE" >&2; exit 2; }
|
||||
ROLE_CEILING="$(printf '%s\n' "$ROLE_OUT" | sed -n 's/^MOSAIC_ROLE_TOOLS=//p')"
|
||||
if [ -n "$TOOLS" ]; then
|
||||
REQUESTED_TOOLS="$TOOLS"
|
||||
TOOLS="$(node -e 'const c=process.argv[1].split(",").filter(Boolean);const r=process.argv[2].split(",").filter(Boolean);process.stdout.write(r.filter(t=>c.includes(t)).join(","))' "$ROLE_CEILING" "$REQUESTED_TOOLS")"
|
||||
if [ -z "$TOOLS" ]; then
|
||||
echo "agent: capability policy: role '$ROLE' ceiling and requested tools have nothing in common -> tool-free seat" >&2
|
||||
elif [ "$TOOLS" != "$REQUESTED_TOOLS" ]; then
|
||||
echo "agent: capability policy: role '$ROLE' ceiling narrowed tools -> $TOOLS" >&2
|
||||
fi
|
||||
else
|
||||
TOOLS="$ROLE_CEILING"
|
||||
fi
|
||||
fi
|
||||
export MOSAIC_TOOLS="${TOOLS:+$TOOLS}"
|
||||
|
||||
# Skills (M17): seat definition may declare skill names; each must be
|
||||
|
||||
+35
-1
@@ -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)`);
|
||||
}
|
||||
|
||||
@@ -236,6 +236,69 @@ EOF
|
||||
grep -q '^MOSAIC_SKILLS=/var/lib/mosaic/skills-enabled/ms-tools$' "$SANDBOX/seat-stderr.txt" 2>/dev/null \
|
||||
&& check "skill path delivered to adapter" 0 || check "skill path delivered to adapter" 1
|
||||
|
||||
# seat role ceiling (M18): role contracts cap seat capabilities.
|
||||
# Contract validation is CLI-exercised (no docker); the seat wiring gets
|
||||
# two mock-adapter launches (narrowing, tool-free) plus one pre-docker
|
||||
# refusal (missing contract).
|
||||
ROLE_OUT="$(node scripts/mosaic-task.mjs resolve-role roles/researcher.json 2>/dev/null)" \
|
||||
&& printf '%s\n' "$ROLE_OUT" | grep -q '^MOSAIC_ROLE_TOOLS=read,grep,find,ls,bash$' \
|
||||
&& printf '%s\n' "$ROLE_OUT" | grep -q '^MOSAIC_ROLE_NETWORK=none$' \
|
||||
&& check "shipped researcher contract resolves (ceiling + network)" 0 || check "shipped researcher contract resolves (ceiling + network)" 1
|
||||
expect_exit "resolve-role refuses a non-role document (conductor policy)" 2 -- \
|
||||
node scripts/mosaic-task.mjs resolve-role roles/conductor-policy.json
|
||||
mkdir -p "$SANDBOX/roles" "$SANDBOX/agents/roleseat" "$SANDBOX/no-roles"
|
||||
printf '{"roleVersion":1,"name":"other","tools":["read"]}' > "$SANDBOX/roles/mismatch.json"
|
||||
expect_exit "resolve-role refuses name/filename mismatch" 2 -- \
|
||||
node scripts/mosaic-task.mjs resolve-role "$SANDBOX/roles/mismatch.json"
|
||||
printf '{"roleVersion":1,"name":"badnet","tools":["read"],"network":"everywhere"}' > "$SANDBOX/roles/badnet.json"
|
||||
expect_exit "resolve-role refuses unknown network declaration" 2 -- \
|
||||
node scripts/mosaic-task.mjs resolve-role "$SANDBOX/roles/badnet.json"
|
||||
printf '{"roleVersion":1,"name":"dupe","tools":["read","read"]}' > "$SANDBOX/roles/dupe.json"
|
||||
expect_exit "resolve-role refuses duplicate role tool" 2 -- \
|
||||
node scripts/mosaic-task.mjs resolve-role "$SANDBOX/roles/dupe.json"
|
||||
printf '{"roleVersion":1,"name":"aliens","tools":["read","render3d"]}' > "$SANDBOX/roles/aliens.json"
|
||||
expect_exit "resolve-role refuses unsupported tool" 2 -- \
|
||||
node scripts/mosaic-task.mjs resolve-role "$SANDBOX/roles/aliens.json"
|
||||
expect_exit "resolve-role refuses missing contract file" 4 -- \
|
||||
node scripts/mosaic-task.mjs resolve-role "$SANDBOX/roles/absent.json"
|
||||
printf '# SOUL - roleseat\n\nVerifies before claiming.\n' > "$SANDBOX/agents/roleseat/SOUL.md"
|
||||
printf '{"agentVersion":1,"name":"roleseat","role":"analyst","capabilities":{"tools":["read","write","bash"]}}' > "$SANDBOX/agents/roleseat/agent.json"
|
||||
printf '{"roleVersion":1,"name":"analyst","tools":["read","grep","bash"],"network":"none"}' > "$SANDBOX/roles/analyst.json"
|
||||
env MOSAIC_CONFIG="$SANDBOX/mock-config.json" MOSAIC_MOCK_RESPONSE=MOCKED \
|
||||
MOSAIC_AGENTS_DIR="$SANDBOX/agents" MOSAIC_ROLES_DIR="$SANDBOX/roles" \
|
||||
scripts/agent.sh roleseat </dev/null >"$SANDBOX/roleseat-stdout.txt" 2>"$SANDBOX/roleseat-stderr.txt"
|
||||
RC=$?
|
||||
if [ "$RC" -eq 0 ]; then
|
||||
PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} seat launches under role ceiling"
|
||||
else
|
||||
FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} seat launches under role ceiling (exit $RC)" >&2
|
||||
echo "ROLESEAT stderr:" >&2; cat "$SANDBOX/roleseat-stderr.txt" >&2
|
||||
fi
|
||||
grep -q '^MOSAIC_TOOLS=read,bash$' "$SANDBOX/roleseat-stderr.txt" 2>/dev/null \
|
||||
&& check "role ceiling narrows seat tools (read,write,bash -> read,bash)" 0 || check "role ceiling narrows seat tools (read,write,bash -> read,bash)" 1
|
||||
grep -q "role 'analyst' ceiling narrowed tools -> read,bash" "$SANDBOX/roleseat-stderr.txt" 2>/dev/null \
|
||||
&& check "narrowing recorded loudly" 0 || check "narrowing recorded loudly" 1
|
||||
env MOSAIC_CONFIG="$SANDBOX/mock-config.json" MOSAIC_MOCK_RESPONSE=MOCKED \
|
||||
MOSAIC_AGENTS_DIR="$SANDBOX/agents" MOSAIC_ROLES_DIR="$SANDBOX/no-roles" \
|
||||
scripts/agent.sh roleseat </dev/null >"$SANDBOX/roleseat-stdout.txt" 2>"$SANDBOX/roleseat-stderr.txt"
|
||||
RC=$?
|
||||
if [ "$RC" -eq 2 ]; then
|
||||
PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} missing role contract refuses launch (exit 2)"
|
||||
else
|
||||
FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} missing role contract refuses launch (exit $RC)" >&2
|
||||
echo "ROLESEAT stderr:" >&2; cat "$SANDBOX/roleseat-stderr.txt" >&2
|
||||
fi
|
||||
grep -q "role 'analyst' is declared but has no contract" "$SANDBOX/roleseat-stderr.txt" 2>/dev/null \
|
||||
&& check "missing-contract refusal names the role" 0 || check "missing-contract refusal names the role" 1
|
||||
printf '{"agentVersion":1,"name":"roleseat","role":"analyst","capabilities":{"tools":["write","edit"]}}' > "$SANDBOX/agents/roleseat/agent.json"
|
||||
env MOSAIC_CONFIG="$SANDBOX/mock-config.json" MOSAIC_MOCK_RESPONSE=MOCKED \
|
||||
MOSAIC_AGENTS_DIR="$SANDBOX/agents" MOSAIC_ROLES_DIR="$SANDBOX/roles" \
|
||||
scripts/agent.sh roleseat </dev/null >"$SANDBOX/roleseat-stdout.txt" 2>"$SANDBOX/roleseat-stderr.txt"
|
||||
grep -q '^MOSAIC_TOOLS=$' "$SANDBOX/roleseat-stderr.txt" 2>/dev/null \
|
||||
&& check "empty ceiling intersection -> tool-free seat" 0 || check "empty ceiling intersection -> tool-free seat" 1
|
||||
grep -q "nothing in common -> tool-free seat" "$SANDBOX/roleseat-stderr.txt" 2>/dev/null \
|
||||
&& check "tool-free outcome recorded loudly" 0 || check "tool-free outcome recorded loudly" 1
|
||||
|
||||
# capability policy (M9): least-privilege intersection
|
||||
POL="$SANDBOX/data/workspaces"; mkdir -p "$POL"
|
||||
pol_run() { # missionTools(ABSENT|json) taskTools(ABSENT|json) -> stderr MOSAIC_TOOLS value
|
||||
|
||||
Reference in New Issue
Block a user