From 2ff49adff432790149031443b978560585dfe697 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 3 Sep 2026 06:16:01 -0500 Subject: [PATCH] feat(policy): mission-level capability policy - least-privilege intersection (#30) - mission schema: optional capabilities.tools (same validation as task) - merge semantics in runTask: neither -> none; mission only -> mission; task only -> task; both -> intersection (task narrows, never widens); empty intersection -> tool-free run with an explicit stderr note - result.json records EFFECTIVE tools; task/mission snapshots remain the immutable declaration of intent - adapters unchanged; host-side only (no image change, 0.0.6 still active) - task suite +5 cases (41 total): all four merge cases asserted from run evidence + invalid mission capabilities rejected Policy decision recorded: missions govern; tasks cannot escalate. Closes #30 --- scripts/mosaic-task.mjs | 47 +++++++++++++++++++++++++++++++++++++---- scripts/test-task.sh | 29 +++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/scripts/mosaic-task.mjs b/scripts/mosaic-task.mjs index 2ef3a4da..2a43f8db 100755 --- a/scripts/mosaic-task.mjs +++ b/scripts/mosaic-task.mjs @@ -84,7 +84,7 @@ function validateId(value, what) { function validateMission(document, file) { if (!isPlainObject(document)) fail(2, "mission must be a JSON object"); - rejectUnknownKeys(document, ["missionVersion", "id", "objective", "directives"], "mission"); + rejectUnknownKeys(document, ["missionVersion", "id", "objective", "directives", "capabilities"], "mission"); if (document.missionVersion !== 1) { fail(2, `unsupported missionVersion: ${JSON.stringify(document.missionVersion)} (supported: 1)`); } @@ -102,7 +102,29 @@ function validateMission(document, file) { return d; }); } - return { missionVersion: document.missionVersion, id: document.id, objective: document.objective, directives }; + + // Governing capability constraints (M9): same validation as task + // capabilities; semantically these BOUND tasks (least-privilege + // intersection at run time), never grant beyond them. + let capabilities = null; + if (document.capabilities !== undefined && document.capabilities !== null) { + if (!isPlainObject(document.capabilities)) fail(2, 'mission "capabilities" must be a JSON object'); + rejectUnknownKeys(document.capabilities, ["tools"], 'mission "capabilities"'); + if (!Array.isArray(document.capabilities.tools) || document.capabilities.tools.length === 0) { + fail(2, 'mission "capabilities.tools" must be a non-empty array of tool names'); + } + const seen = new Set(); + for (const tool of document.capabilities.tools) { + if (!SUPPORTED_TOOLS.includes(tool)) { + fail(2, `unsupported mission tool: ${JSON.stringify(tool)} (supported: ${SUPPORTED_TOOLS.join(", ")})`); + } + if (seen.has(tool)) fail(2, `duplicate tool in mission capabilities.tools: ${tool}`); + seen.add(tool); + } + capabilities = { tools: [...seen] }; + } + + return { missionVersion: document.missionVersion, id: document.id, objective: document.objective, directives, capabilities }; } function validateTask(document, file) { @@ -291,7 +313,24 @@ function runTask(taskFile, options = {}) { workspaceContainerPath = `/var/lib/mosaic/workspaces/${task.workspace}`; } if (workspaceContainerPath) spawnEnv.MOSAIC_WORKSPACE = workspaceContainerPath; - spawnEnv.MOSAIC_TOOLS = task.tools ? task.tools.join(",") : ""; + + // Capability policy (M9): least-privilege intersection. A task may narrow + // a mission's tool grant, never widen it. Empty intersection = tool-free. + let effectiveTools = task.tools; + let policyNote = null; + if (task.missionSnapshot?.capabilities) { + const missionTools = task.missionSnapshot.capabilities.tools; + if (effectiveTools) { + effectiveTools = effectiveTools.filter((t) => missionTools.includes(t)); + if (effectiveTools.length === 0) { + policyNote = `capability policy: mission ${task.missionSnapshot.id} and task request no tools in common -> tool-free run`; + } + } else { + effectiveTools = [...missionTools]; + } + } + if (policyNote) process.stderr.write(`mosaic-task: ${policyNote}\n`); + spawnEnv.MOSAIC_TOOLS = effectiveTools ? effectiveTools.join(",") : ""; // Session (M6): persistent named session dir, passed as container path. if (task.session) { @@ -347,7 +386,7 @@ function runTask(taskFile, options = {}) { expectedExact: expected, ...(options.retriedFrom ? { retriedFrom: options.retriedFrom } : {}), workspace: task.workspace, - tools: task.tools, + tools: effectiveTools, session: task.session, exitCode: proc.status, signal: proc.signal ?? null, diff --git a/scripts/test-task.sh b/scripts/test-task.sh index d50a0302..c3c99d51 100755 --- a/scripts/test-task.sh +++ b/scripts/test-task.sh @@ -157,6 +157,35 @@ EOF || check "mission section present after retry (relative path resolved)" 1 expect_exit "retry of missing run exits 4" 4 -- \ env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" node scripts/mosaic-task.mjs retry r-missing + + # 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 + if [ "$1" = "ABSENT" ]; then + printf '{"missionVersion":1,"id":"m-pol","objective":"o"}' > "$SANDBOX/pol-m.json" + else + printf '{"missionVersion":1,"id":"m-pol","objective":"o","capabilities":{"tools":[%s]}}' "$1" > "$SANDBOX/pol-m.json" + fi + if [ "$2" = "ABSENT" ]; then + printf '{"taskVersion":1,"id":"t-pol","prompt":"x","mission":"pol-m.json"}' > "$SANDBOX/pol-t.json" + else + printf '{"taskVersion":1,"id":"t-pol","prompt":"x","mission":"pol-m.json","capabilities":{"tools":[%s]}}' "$2" > "$SANDBOX/pol-t.json" + fi + env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" MOSAIC_MOCK_RESPONSE=MOCKED \ + scripts/run-task.sh run "$SANDBOX/pol-t.json" >/dev/null 2>&1 + grep -o '^MOSAIC_TOOLS=.*' "$(ls -dt "$SANDBOX/data/runs"/r-* | head -1)/stderr.txt" 2>/dev/null + } + [ "$(pol_run '"read","bash"' ABSENT)" = 'MOSAIC_TOOLS=read,bash' ] \ + && check "policy: mission only -> mission tools" 0 || check "policy: mission only -> mission tools" 1 + [ "$(pol_run ABSENT '"read","bash"')" = 'MOSAIC_TOOLS=read,bash' ] \ + && check "policy: task only -> task tools" 0 || check "policy: task only -> task tools" 1 + [ "$(pol_run '"read"' '"bash","read"')" = 'MOSAIC_TOOLS=read' ] \ + && check "policy: both -> intersection (task narrowed)" 0 || check "policy: both -> intersection (task narrowed)" 1 + [ "$(pol_run '"grep"' '"bash","read"')" = 'MOSAIC_TOOLS=' ] \ + && check "policy: empty intersection -> tool-free" 0 || check "policy: empty intersection -> tool-free" 1 + printf '{"missionVersion":1,"id":"m-pol","objective":"o","capabilities":{"tools":["sudo"]}}' > "$SANDBOX/pol-m.json" + expect_exit "invalid mission capabilities rejected" 2 -- \ + env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" $TASK validate "$SANDBOX/pol-t.json" else echo "skip adapter seam cases (docker daemon unavailable)" fi