+43
-4
@@ -84,7 +84,7 @@ function validateId(value, what) {
|
|||||||
|
|
||||||
function validateMission(document, file) {
|
function validateMission(document, file) {
|
||||||
if (!isPlainObject(document)) fail(2, "mission must be a JSON object");
|
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) {
|
if (document.missionVersion !== 1) {
|
||||||
fail(2, `unsupported missionVersion: ${JSON.stringify(document.missionVersion)} (supported: 1)`);
|
fail(2, `unsupported missionVersion: ${JSON.stringify(document.missionVersion)} (supported: 1)`);
|
||||||
}
|
}
|
||||||
@@ -102,7 +102,29 @@ function validateMission(document, file) {
|
|||||||
return d;
|
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) {
|
function validateTask(document, file) {
|
||||||
@@ -291,7 +313,24 @@ function runTask(taskFile, options = {}) {
|
|||||||
workspaceContainerPath = `/var/lib/mosaic/workspaces/${task.workspace}`;
|
workspaceContainerPath = `/var/lib/mosaic/workspaces/${task.workspace}`;
|
||||||
}
|
}
|
||||||
if (workspaceContainerPath) spawnEnv.MOSAIC_WORKSPACE = workspaceContainerPath;
|
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.
|
// Session (M6): persistent named session dir, passed as container path.
|
||||||
if (task.session) {
|
if (task.session) {
|
||||||
@@ -347,7 +386,7 @@ function runTask(taskFile, options = {}) {
|
|||||||
expectedExact: expected,
|
expectedExact: expected,
|
||||||
...(options.retriedFrom ? { retriedFrom: options.retriedFrom } : {}),
|
...(options.retriedFrom ? { retriedFrom: options.retriedFrom } : {}),
|
||||||
workspace: task.workspace,
|
workspace: task.workspace,
|
||||||
tools: task.tools,
|
tools: effectiveTools,
|
||||||
session: task.session,
|
session: task.session,
|
||||||
exitCode: proc.status,
|
exitCode: proc.status,
|
||||||
signal: proc.signal ?? null,
|
signal: proc.signal ?? null,
|
||||||
|
|||||||
@@ -157,6 +157,35 @@ EOF
|
|||||||
|| check "mission section present after retry (relative path resolved)" 1
|
|| check "mission section present after retry (relative path resolved)" 1
|
||||||
expect_exit "retry of missing run exits 4" 4 -- \
|
expect_exit "retry of missing run exits 4" 4 -- \
|
||||||
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" node scripts/mosaic-task.mjs retry r-missing
|
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
|
else
|
||||||
echo "skip adapter seam cases (docker daemon unavailable)"
|
echo "skip adapter seam cases (docker daemon unavailable)"
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user