From ca8135d70c0f8f852e4b58d9d491135bbda0f0bb Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 3 Sep 2026 17:25:17 -0500 Subject: [PATCH] feat(roles): M18 seat-role progressive capability restriction (#45) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Role contracts (roles/.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. --- AGENTS.md | 2 +- BUILD-LOG.md | 32 +++++++++++++++++++++ docs/SESSIONS.md | 1 + docs/TOOLS.md | 8 +++++- docs/plans/CURRENT.md | 7 +++-- roles/researcher.json | 6 ++++ scripts/agent.sh | 23 +++++++++++++++ scripts/mosaic-task.mjs | 36 ++++++++++++++++++++++- scripts/test-task.sh | 63 +++++++++++++++++++++++++++++++++++++++++ 9 files changed, 172 insertions(+), 6 deletions(-) create mode 100644 roles/researcher.json diff --git a/AGENTS.md b/AGENTS.md index 660ec222..a8f948ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,7 +68,7 @@ green at every step. Not production software — a proven foundation. `scripts/bootstrap.sh` (idempotent) · `build.sh` · `hello.sh` · `verify.sh` · `run-task.sh run ` · `release.sh package|activate|rollback|status` · `reset.sh` (**danger**: wipes the data -root; triple-safety-checked) · `mosaic-task.mjs validate|run|show|list|retry|prune` · +root; triple-safety-checked) · `mosaic-task.mjs validate|run|show|list|retry|prune|resolve-role` · `agent.sh ` (interactive TUI agent) · suites: `test-config.sh`, `test-task.sh`, `test-release.sh`, `test-conductor.sh`. diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 3f808816..725d5780 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -467,3 +467,35 @@ Release self-determination live: the system aligns itself to RELEASE without man The conductor loop is proven end to end on a live seat; TOOLS.md covers the tools/ tree; suites green at every gate. + +## Phase 23: M18 — seat-role progressive capability restriction + +- Role contracts: `roles/.json` — roleVersion, name (must match + filename), tools ceiling (subset of pi built-ins), network declared + (none | api-only | open; enforcement is a later milestone). Strict + schema, fail closed; a non-role document (e.g. conductor-policy.json) + refuses. +- `mosaic-task.mjs resolve-role `: config-free validation, emits + MOSAIC_ROLE_TOOLS / MOSAIC_ROLE_NETWORK for agent.sh to consume. +- `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 are loud on stderr. + Adapters unchanged (MOSAIC_TOOLS carries the effective set); headless + M9 chain (mission ∩ task) untouched. +- Ships roles/researcher.json — the existing researcher seat declares the + role; without the contract the fail-closed gate would refuse its launch. +- Suite additions (14 cases, task suite 74 → 88): contract resolution, + wrong-kind/name/network/duplicate/unsupported/missing refusals, seat + narrowing E2E via mock adapter, tool-free E2E, missing-contract refusal. +- Test-authoring correction: the first version of the missing-contract + case registered its check only on the failure path (a `|| RC=$?` chain + swallowed it on success); caught by count arithmetic (74 + 15 ≠ 88), + restructured so the case always registers. + +## Result (M18) + +Seat roles are ceilings, not labels: the M15 role field now resolves to a +versioned contract that seats cannot escalate past. Suites 24/88/14/17 + +verify green. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 22280baf..29578690 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -11,3 +11,4 @@ are never rewritten or removed; corrections are new entries. | 2026-09-03 | assistant | ms-communications skill: inter-agent messaging protocol consolidated from tools/tmux/README.md and agent-send.sh (channel, preamble grammar, flip-on-reply, triage classes, etiquette, receiving protocol, delivery mechanics) | skills/ms-communications/SKILL.md created; unslop-check clean | | 2026-09-03 | assistant (conductor) + ms-test collaborator (worker, glm-5.3-flash) | Conductor-loop calibration (#43): decompose → dispatch via agent-send.sh → receipt → line-by-line diff review (claims verified vs tool source) → suite-gated integration; CURRENT.md staleness corrected (M16/M17 late-logged, next action → M18) | docs/TOOLS.md tools/ section + suite-count fix; issue #43 closed; suites 24/74/14/17 + verify green | | 2026-09-03 | owner + assistant (conductor) + ms-test collaborator | Skill revisions adjudicated (#44): ms-communications integrated as-authored (owner preamble restructure + collaborator delivery-discipline hunks); ms-conductor collaborator redraft integrated with conductor remediation (step 3 refusal-vs-outage distinction; preserves owner's outage-dispatch intent inside fail-closed canon); TOOLS.md gains release.sh ensure row | skills/ms-communications/SKILL.md, skills/ms-conductor/SKILL.md, docs/TOOLS.md; suites 24/74/14/17 + verify green; unslop clean ×3 | +| 2026-09-03 | assistant (conductor) | M18 seat-role progressive capability restriction (#45): roles/.json contracts (strict schema, name-filename binding, network declared), mosaic-task.mjs resolve-role, agent.sh ceiling intersection with fail-closed refusals, roles/researcher.json shipped, 14 suite cases (task 74 → 88) | scripts/mosaic-task.mjs, scripts/agent.sh, scripts/test-task.sh, roles/researcher.json, docs; suites 24/88/14/17 + verify green | diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 401d9604..37e26adf 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -30,6 +30,7 @@ and changes nothing. | `node scripts/mosaic-task.mjs list` | List runs | task/workspace/session columns | | `node scripts/mosaic-task.mjs retry ` | Re-execute a run's snapshot | New run dir; `retriedFrom` lineage recorded | | `node scripts/mosaic-task.mjs prune [--keep=N] [--yes]` | Retention | Dry-run default; receipt in `runs/.pruned.log` | +| `node scripts/mosaic-task.mjs resolve-role ` | Validate a role contract | Prints `MOSAIC_ROLE_TOOLS` / `MOSAIC_ROLE_NETWORK`; config-free | Task fields: `prompt` (required), `mission` (path), `expectExact`, `timeoutSeconds` (5–600), `workspace` (`:run` or named), `capabilities.tools` @@ -48,6 +49,11 @@ Launches an interactive pi TUI inside the container with the four immutable contracts + optional mission + agent identity as its system prompt, persistent named session, optional workspace. Exit with `/quit`. +A seat role (`agent.json` `role`) binds to `roles/.json` (M18): the +contract's tools are a ceiling the seat definition or `--tools` may narrow, +never escalate past. Missing/invalid contract refuses the launch; empty +intersection = loud tool-free seat. + ## Release | Command | Purpose | Notes | @@ -75,7 +81,7 @@ Push is never automatic. |---|---|---| | `scripts/reset.sh` | Delete the data root | Triple-safety-checked (path, symlink, ownership marker) | | `scripts/test-config.sh` | Config selftests (no Docker) | 24 cases | -| `scripts/test-task.sh` | Task selftests + live cases | 74 cases | +| `scripts/test-task.sh` | Task selftests + live cases | 88 cases | | `scripts/test-release.sh` | Release selftests | 14 cases | | `scripts/test-conductor.sh` | Auto-apply selftests (sandboxed) | 17 cases | | `scripts/gitea-api.sh [body]` | Gitea API helper | Token never on argv/stdout | diff --git a/docs/plans/CURRENT.md b/docs/plans/CURRENT.md index ce562592..01c99acc 100644 --- a/docs/plans/CURRENT.md +++ b/docs/plans/CURRENT.md @@ -7,12 +7,11 @@ update this file to the next action). No ambiguity, no re-planning. ## Next action -M18 — seat-role progressive capability restriction (role ceilings ∩ mission ∩ task) — say "next" to execute. +M19 — harness auth tooling (pi checkpoint: auth status, provider stacking, multi-account investigation) — say "next" to execute. ## Queue (ordered per docs/plans/ROADMAP.md) -1. M19 — harness auth tooling (pi checkpoint: auth status, provider stacking, multi-account investigation) -2. Deferred by owner: CI runners (Gitea hardware slow); second real adapter; push automation +1. Deferred by owner: CI runners (Gitea hardware slow); second real adapter; push automation ## Rules @@ -50,3 +49,5 @@ git history + Gitea issues. - 2026-09-03 — M16 release self-determination (#38; `release.sh ensure` at launch, drift warnings, recursion guard) — logged late: CURRENT.md had gone stale while M16/M17 shipped; ground truth = git history - 2026-09-03 — M17 skill lifecycle + ms-* skill set completion (#40–#42; skill-lifecycle-v1); release 0.0.12 packaged, health-gated active — logged late, same staleness correction - 2026-09-03 — conductor-loop calibration with live collaborator (#43): dispatch via agent-send.sh → receipt → line-by-line diff review → suite-gated integration; docs/TOOLS.md gains Tools (host-side) section + corrected suite counts +- 2026-09-03 — skill revisions adjudicated (#44): ms-communications integrated as-authored; ms-conductor redraft + conductor remediation (refusal vs outage); TOOLS.md release.sh ensure row +- 2026-09-03 — M18 seat-role progressive capability restriction (#45; roles resolve to contracts, ceiling ∩ seat grant, fail-closed refusals, roles/researcher.json); task suite 74 → 88 diff --git a/roles/researcher.json b/roles/researcher.json new file mode 100644 index 00000000..026a581e --- /dev/null +++ b/roles/researcher.json @@ -0,0 +1,6 @@ +{ + "roleVersion": 1, + "name": "researcher", + "tools": ["read", "grep", "find", "ls", "bash"], + "network": "none" +} diff --git a/scripts/agent.sh b/scripts/agent.sh index d1c8f943..52e2b76e 100755 --- a/scripts/agent.sh +++ b/scripts/agent.sh @@ -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/.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 diff --git a/scripts/mosaic-task.mjs b/scripts/mosaic-task.mjs index ea85360f..ffd063e8 100755 --- a/scripts/mosaic-task.mjs +++ b/scripts/mosaic-task.mjs @@ -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 "); + 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 "); 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)`); } diff --git a/scripts/test-task.sh b/scripts/test-task.sh index 53db23dc..ee153892 100755 --- a/scripts/test-task.sh +++ b/scripts/test-task.sh @@ -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 "$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 "$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 "$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