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
+63
View File
@@ -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