667 lines
32 KiB
TypeScript
667 lines
32 KiB
TypeScript
// lib/policy.ts — pure role-manifest schema validation (PRD R2, AC1; NG-4 v3).
|
|
//
|
|
// NG-1 keeps PURE validation only: no filesystem load, no trusted-path
|
|
// resolution (NG-2 owns those). The schema is deliberately strict —
|
|
// everything not named here is rejected, because a manifest that silently
|
|
// tolerates extra fields is a manifest whose future drift cannot be caught.
|
|
//
|
|
// NG-4 (velma B2, memo 9c426fa9): the manifest separates CAPABILITIES from
|
|
// tool bindings. Capabilities are the authority; Pi tools are their bound
|
|
// projections. The strict cross-check: the tools array must be EXACTLY the
|
|
// set of Pi bindings of the bound capabilities — neither more (an
|
|
// unbound-to-capability tool is undeclared authority) nor less.
|
|
//
|
|
// NG-4 remediation F6: the manifest carries the FULL authority set —
|
|
// schemaVersion, extensions.load order, workspace.readRoots, credentials
|
|
// (deny-all), evidence permissions (journal-only), and digest/integrity
|
|
// rules. Settings are generated projections; they never become authority.
|
|
|
|
export interface CapabilityBinding {
|
|
id: string;
|
|
effect: CapabilityEffect;
|
|
binding?: string;
|
|
status: CapabilityStatus;
|
|
}
|
|
|
|
export interface RoleManifestV3 {
|
|
schemaVersion: 3;
|
|
role: string;
|
|
revision: number;
|
|
capabilities: CapabilityBinding[];
|
|
tools: string[];
|
|
extensions: { load: string[] };
|
|
workspace: { readRoots: string[] };
|
|
credentials: Record<string, unknown>;
|
|
evidence: { journal: true; journalEvents: string[]; external: "denied" };
|
|
digestRules: { algorithm: "sha256"; scope: "manifest-bytes"; postcondition: "active-set-exact" };
|
|
forbiddenTools: string[];
|
|
shell: { mode: "denied" };
|
|
}
|
|
|
|
export interface GoalReportPolicy {
|
|
format: "ms-executive-update/v1";
|
|
contractPath: "skills-local/ms-executive-update/SKILL.md";
|
|
contractSection: "Machine contract (for `goal_report` payloads and any parser)";
|
|
contractBlob: "df30c6fbb54b4a65a298c9e51c07f742610d171c";
|
|
contractSha256: "bbea48a46b1f8da7bc759f86856fb52830b7dde456b826317163c6dc6ccab319";
|
|
enforcement: "pre-state-change-fail-closed";
|
|
identifierResolution: "consumer-fail-closed";
|
|
}
|
|
|
|
export interface RoleManifestV4 {
|
|
schemaVersion: 4;
|
|
role: "gate-merge-ng" | "plan-ng" | "review-ng";
|
|
revision: 3 | 4;
|
|
capabilities: CapabilityBinding[];
|
|
tools: string[];
|
|
extensions: { load: string[] };
|
|
workspace: { readRoots: string[] };
|
|
credentials: Record<string, unknown>;
|
|
evidence: Record<string, unknown>;
|
|
digestRules: { algorithm: "sha256"; scope: "manifest-bytes"; postcondition: "active-set-exact" };
|
|
forbiddenTools: string[];
|
|
shell: { mode: "denied" };
|
|
goalReportPolicy: GoalReportPolicy;
|
|
targetPolicy?: { kind: "pull-request"; hosts: string[]; repositories: string[]; pinSource: "assignment" };
|
|
}
|
|
|
|
export type RoleManifest = RoleManifestV3 | RoleManifestV4;
|
|
|
|
export type CapabilityEffect = "observe" | "propose" | "transition" | "communicate";
|
|
export type CapabilityStatus = "bound" | "unbound";
|
|
|
|
/** Closed capability ids. C1-C8 remain version-3 only; C9-C11 are version-4 gate authority. */
|
|
const V3_CAPABILITY_IDS = new Set([
|
|
"policy.status", // C1
|
|
"improvement.propose", // C2
|
|
"repo.file.read", // C3
|
|
"goal.report", // C4
|
|
"reason.structured", // C5
|
|
"coord.route_implementation", // C6
|
|
"coord.request", // C7
|
|
"comms.send", // C8
|
|
]);
|
|
export const CAPABILITY_IDS = new Set([
|
|
...V3_CAPABILITY_IDS,
|
|
"repo.target.inspect", // C9
|
|
"gate.evidence.observe", // C10
|
|
"gate.verdict", // C11
|
|
]);
|
|
|
|
export const CAPABILITY_EFFECTS = new Set<CapabilityEffect>(["observe", "propose", "transition", "communicate"]);
|
|
|
|
/** Fixed normative id-to-binding maps. Version 3 never learns C9-C11. */
|
|
const V3_NORMATIVE_BINDINGS: Record<string, { binding: string; status: "bound" } | { status: "unbound" }> = {
|
|
"policy.status": { binding: "mosaic_policy_status", status: "bound" },
|
|
"improvement.propose": { binding: "mosaic_improvement_propose", status: "bound" },
|
|
"repo.file.read": { binding: "read", status: "bound" },
|
|
"goal.report": { binding: "goal_report", status: "bound" },
|
|
"reason.structured": { status: "unbound" },
|
|
"coord.route_implementation": { status: "unbound" },
|
|
"coord.request": { status: "unbound" },
|
|
"comms.send": { status: "unbound" },
|
|
};
|
|
export const NORMATIVE_BINDINGS: Record<string, { binding: string; status: "bound" } | { status: "unbound" }> = {
|
|
...V3_NORMATIVE_BINDINGS,
|
|
"repo.target.inspect": { binding: "mosaic_gate_target_read", status: "bound" },
|
|
"gate.evidence.observe": { binding: "mosaic_gate_evidence_observe", status: "bound" },
|
|
"gate.verdict": { binding: "mosaic_gate_verdict", status: "bound" },
|
|
};
|
|
|
|
/**
|
|
* NG4-CRIT: the MANDATORY reviewed forbidden raw-tool set. The manifest's
|
|
* forbiddenTools must CONTAIN this minimum set (it may add more, never
|
|
* remove). This prevents the caller from deleting entries to smuggle a
|
|
* forbidden tool back into scope.
|
|
*/
|
|
export const MANDATORY_FORBIDDEN = ["bash", "edit", "write", "powershell", "tmux", "send_message", "git"];
|
|
export const CAPABILITY_STATUSES = new Set<CapabilityStatus>(["bound", "unbound"]);
|
|
|
|
export type PolicyRejection =
|
|
| "not-an-object"
|
|
| "unknown-field"
|
|
| "missing-field"
|
|
| "invalid-role"
|
|
| "invalid-revision"
|
|
| "invalid-schema-version"
|
|
| "invalid-tools"
|
|
| "duplicate-tool"
|
|
| "unsafe-empty-policy"
|
|
| "invalid-capabilities"
|
|
| "duplicate-capability"
|
|
| "unknown-capability"
|
|
| "invalid-capability-effect"
|
|
| "invalid-capability-status"
|
|
| "bound-without-binding"
|
|
| "unbound-with-binding"
|
|
| "invalid-capability-binding"
|
|
| "tools-bindings-mismatch"
|
|
| "invalid-extensions"
|
|
| "invalid-workspace"
|
|
| "invalid-credentials"
|
|
| "invalid-evidence"
|
|
| "invalid-digest-rules"
|
|
| "missing-capability"
|
|
| "invalid-forbidden-tools"
|
|
| "invalid-shell"
|
|
| "forbidden-tool-overlap"
|
|
| "invalid-journal-events"
|
|
| "invalid-role-class"
|
|
| "invalid-goal-report-policy"
|
|
| "invalid-target-policy"
|
|
| "version4-exact-set-mismatch";
|
|
|
|
export interface PolicyResult {
|
|
ok: boolean;
|
|
manifest?: RoleManifest;
|
|
reason?: PolicyRejection;
|
|
detail?: string;
|
|
}
|
|
|
|
const V3_ALLOWED_FIELDS = new Set([
|
|
"schemaVersion", "role", "revision", "tools", "capabilities",
|
|
"extensions", "workspace", "credentials", "evidence", "digestRules",
|
|
"forbiddenTools", "shell",
|
|
]);
|
|
const ROLE_RE = /^[a-z][a-z0-9-]*$/;
|
|
const TOOL_RE = /^[a-z0-9][a-z0-9_-]*$/;
|
|
const EXT_RE = /^[a-z][a-z0-9-]*$/;
|
|
|
|
export function validateManifest(input: unknown): PolicyResult {
|
|
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
return { ok: false, reason: "not-an-object", detail: "manifest must be a JSON object" };
|
|
}
|
|
const obj = input as Record<string, unknown>;
|
|
|
|
// Version 3 remains on its original closed validator. Version 4 is a
|
|
// separate role-class contract and never widens the v3 capability map.
|
|
if (obj.schemaVersion === 4) return validateV4Manifest(obj);
|
|
|
|
for (const key of Object.keys(obj)) {
|
|
if (!V3_ALLOWED_FIELDS.has(key)) {
|
|
return { ok: false, reason: "unknown-field", detail: `unexpected field "${key}"` };
|
|
}
|
|
}
|
|
for (const field of V3_ALLOWED_FIELDS) {
|
|
if (!(field in obj)) {
|
|
return { ok: false, reason: "missing-field", detail: `missing field "${field}"` };
|
|
}
|
|
}
|
|
|
|
if (obj.schemaVersion !== 3) {
|
|
return { ok: false, reason: "invalid-schema-version", detail: "schemaVersion must be 3" };
|
|
}
|
|
|
|
const role = obj.role;
|
|
if (typeof role !== "string" || !ROLE_RE.test(role)) {
|
|
return { ok: false, reason: "invalid-role", detail: `role must match ${ROLE_RE}` };
|
|
}
|
|
|
|
const revision = obj.revision;
|
|
if (typeof revision !== "number" || !Number.isSafeInteger(revision) || revision < 1) {
|
|
return { ok: false, reason: "invalid-revision", detail: "revision must be a positive integer" };
|
|
}
|
|
|
|
// ---- capabilities: the authority layer ------------------------------------
|
|
const rawCaps = obj.capabilities;
|
|
if (!Array.isArray(rawCaps) || rawCaps.length === 0 || rawCaps.some((c) => typeof c !== "object" || c === null || Array.isArray(c))) {
|
|
return { ok: false, reason: "invalid-capabilities", detail: "capabilities must be a non-empty array of objects" };
|
|
}
|
|
const seenCaps = new Set<string>();
|
|
const boundTools: string[] = [];
|
|
const capabilities: CapabilityBinding[] = [];
|
|
for (const raw of rawCaps) {
|
|
const c = raw as Record<string, unknown>;
|
|
for (const key of Object.keys(c)) {
|
|
if (!["id", "effect", "binding", "status"].includes(key)) {
|
|
return { ok: false, reason: "invalid-capabilities", detail: `unknown capability field "${key}"` };
|
|
}
|
|
}
|
|
if (typeof c.id !== "string" || !V3_CAPABILITY_IDS.has(c.id)) {
|
|
return { ok: false, reason: "unknown-capability", detail: `capability id "${String(c.id)}" is outside the reviewed C1-C8 map` };
|
|
}
|
|
if (seenCaps.has(c.id)) {
|
|
return { ok: false, reason: "duplicate-capability", detail: `duplicate capability "${c.id}"` };
|
|
}
|
|
seenCaps.add(c.id);
|
|
if (typeof c.effect !== "string" || !CAPABILITY_EFFECTS.has(c.effect as CapabilityEffect)) {
|
|
return { ok: false, reason: "invalid-capability-effect", detail: `capability "${c.id}"` };
|
|
}
|
|
// F6H-(5): enforce the normative id->effect mapping from the C1-C8 map
|
|
const NORMATIVE_EFFECT: Record<string, CapabilityEffect> = {
|
|
"policy.status": "observe",
|
|
"improvement.propose": "propose",
|
|
"repo.file.read": "observe",
|
|
"goal.report": "transition",
|
|
"reason.structured": "observe",
|
|
"coord.route_implementation": "transition",
|
|
"coord.request": "transition",
|
|
"comms.send": "communicate",
|
|
};
|
|
if (c.effect !== NORMATIVE_EFFECT[c.id]) {
|
|
return { ok: false, reason: "invalid-capability-effect", detail: `capability "${c.id}" must have effect "${NORMATIVE_EFFECT[c.id]}" (normative C-map), got "${c.effect}"` };
|
|
}
|
|
if (typeof c.status !== "string" || !CAPABILITY_STATUSES.has(c.status as CapabilityStatus)) {
|
|
return { ok: false, reason: "invalid-capability-status", detail: `capability "${c.id}"` };
|
|
}
|
|
// NG4-CRIT: enforce the NORMATIVE binding (or mandatory unbound status)
|
|
const normative = V3_NORMATIVE_BINDINGS[c.id];
|
|
if (normative.status === "unbound" && c.status === "bound") {
|
|
return { ok: false, reason: "invalid-capability-status", detail: `capability "${c.id}" must be UNBOUND in this slice (no backend)` };
|
|
}
|
|
if (c.status === "bound") {
|
|
if (normative.status !== "bound") {
|
|
return { ok: false, reason: "invalid-capability-status", detail: `capability "${c.id}" cannot be bound in this slice` };
|
|
}
|
|
if (c.binding !== normative.binding) {
|
|
return { ok: false, reason: "invalid-capability-binding", detail: `capability "${c.id}" must bind exactly "${normative.binding}" (normative C-map), got "${c.binding}"` };
|
|
}
|
|
if (typeof c.binding !== "string" || !TOOL_RE.test(c.binding)) {
|
|
return { ok: false, reason: "bound-without-binding", detail: `capability "${c.id}" is bound but has no valid tool binding` };
|
|
}
|
|
boundTools.push(c.binding);
|
|
} else if (c.binding !== undefined) {
|
|
return { ok: false, reason: "unbound-with-binding", detail: `capability "${c.id}" is unbound but declares a binding` };
|
|
}
|
|
capabilities.push({
|
|
id: c.id,
|
|
effect: c.effect as CapabilityEffect,
|
|
binding: c.binding as string | undefined,
|
|
status: c.status as CapabilityStatus,
|
|
});
|
|
}
|
|
|
|
// ---- tools: the PROJECTION of bound capabilities, exactly ------------------
|
|
const tools = obj.tools;
|
|
if (!Array.isArray(tools) || tools.some((t) => typeof t !== "string" || !TOOL_RE.test(t))) {
|
|
return { ok: false, reason: "invalid-tools", detail: `every tool must match ${TOOL_RE}` };
|
|
}
|
|
if (tools.length === 0) {
|
|
return { ok: false, reason: "unsafe-empty-policy", detail: "tools must not be empty" };
|
|
}
|
|
const seen = new Set<string>();
|
|
for (const t of tools) {
|
|
if (seen.has(t)) {
|
|
return { ok: false, reason: "duplicate-tool", detail: `duplicate tool "${t}"` };
|
|
}
|
|
seen.add(t);
|
|
}
|
|
const expected = [...new Set(boundTools)].sort();
|
|
const declared = [...new Set(tools)].sort();
|
|
if (JSON.stringify(expected) !== JSON.stringify(declared)) {
|
|
return {
|
|
ok: false,
|
|
reason: "tools-bindings-mismatch",
|
|
detail: `tools must be exactly the bound capabilities' bindings (expected [${expected.join(", ")}])`,
|
|
};
|
|
}
|
|
|
|
// ---- NG4-CRIT3: C1-C4 MUST be present and bound (canary contract) -------------
|
|
const REQUIRED_BOUND = ["policy.status", "improvement.propose", "repo.file.read", "goal.report"];
|
|
for (const reqId of REQUIRED_BOUND) {
|
|
const cap = capabilities.find((c) => c.id === reqId);
|
|
if (!cap) {
|
|
return { ok: false, reason: "missing-capability", detail: `canary roles must declare capability "${reqId}" (C1-C4 are mandatory)` };
|
|
}
|
|
if (cap.status !== "bound") {
|
|
return { ok: false, reason: "invalid-capability-status", detail: `capability "${reqId}" must be BOUND (C1-C4 are mandatory for canary roles)` };
|
|
}
|
|
}
|
|
// C5-C8: if present, must be unbound (first slice — no backend)
|
|
for (const cap of capabilities) {
|
|
if (V3_NORMATIVE_BINDINGS[cap.id]?.status === "unbound" && cap.status === "bound") {
|
|
return { ok: false, reason: "invalid-capability-status", detail: `capability "${cap.id}" must be UNBOUND in this slice` };
|
|
}
|
|
}
|
|
|
|
// ---- extensions: load order authority ----------------------------------------
|
|
const exts = obj.extensions;
|
|
if (typeof exts !== "object" || exts === null || Array.isArray(exts) || !Array.isArray((exts as Record<string, unknown>).load)) {
|
|
return { ok: false, reason: "invalid-extensions", detail: "extensions.load must be an ordered array" };
|
|
}
|
|
// F6H-(2): reject unknown nested keys in extensions
|
|
for (const ek of Object.keys(exts)) {
|
|
if (ek !== "load") {
|
|
return { ok: false, reason: "invalid-extensions", detail: `unknown extensions field "${ek}"` };
|
|
}
|
|
}
|
|
const loadOrder = (exts as { load: unknown[] }).load;
|
|
if (loadOrder.some((e) => typeof e !== "string" || !EXT_RE.test(e))) {
|
|
return { ok: false, reason: "invalid-extensions", detail: "extension names must be slugs" };
|
|
}
|
|
if (new Set(loadOrder).size !== loadOrder.length) {
|
|
return { ok: false, reason: "invalid-extensions", detail: "duplicate extension in load order" };
|
|
}
|
|
// F6H-(3): the reviewed required load order is exact — goal FIRST (registers
|
|
// goal_report), mosaic-core LAST (reconciles the complete registry).
|
|
// Anything else is unreviewed authority.
|
|
const REQUIRED_LOAD = ["goal", "mosaic-core"];
|
|
if (JSON.stringify(loadOrder) !== JSON.stringify(REQUIRED_LOAD)) {
|
|
return { ok: false, reason: "invalid-extensions", detail: `extensions.load must be exactly [${REQUIRED_LOAD.join(", ")}] — goal first (registers goal_report), mosaic-core last (reconciles the complete registry)` };
|
|
}
|
|
|
|
// ---- workspace: read-scope roots (symbolic @brain or absolute) ---------------
|
|
const ws = obj.workspace;
|
|
if (typeof ws !== "object" || ws === null || Array.isArray(ws) || !Array.isArray((ws as Record<string, unknown>).readRoots)) {
|
|
return { ok: false, reason: "invalid-workspace", detail: "workspace.readRoots must be an array" };
|
|
}
|
|
// F6H-(2): reject unknown nested keys in workspace
|
|
for (const wk of Object.keys(ws)) {
|
|
if (wk !== "readRoots") {
|
|
return { ok: false, reason: "invalid-workspace", detail: `unknown workspace field "${wk}"` };
|
|
}
|
|
}
|
|
const readRoots = (ws as { readRoots: unknown[] }).readRoots;
|
|
if (readRoots.some((r) => typeof r !== "string" || !(r.startsWith("/") || r === "@brain"))) {
|
|
return { ok: false, reason: "invalid-workspace", detail: "readRoots must be absolute paths or the symbolic @brain root" };
|
|
}
|
|
|
|
// ---- credentials: deny-all, EXACT (F6) ----------------------------------------
|
|
const creds = obj.credentials;
|
|
if (typeof creds !== "object" || creds === null || Array.isArray(creds)) {
|
|
return { ok: false, reason: "invalid-credentials", detail: "credentials must be an object" };
|
|
}
|
|
const credKeys = Object.keys(creds);
|
|
if (credKeys.some((k) => !["store", "helper", "providerTokens"].includes(k))) {
|
|
return { ok: false, reason: "invalid-credentials", detail: "unknown credentials field" };
|
|
}
|
|
if (creds.store !== "none" || creds.helper !== "none") {
|
|
return { ok: false, reason: "invalid-credentials", detail: "canary roles carry no credential store or helper" };
|
|
}
|
|
if (creds.providerTokens !== "denied") {
|
|
return { ok: false, reason: "invalid-credentials", detail: 'providerTokens must be exactly "denied" (mandatory deny-all)' };
|
|
}
|
|
|
|
// ---- evidence: journal-only (F6) ----------------------------------------------
|
|
const ev = obj.evidence;
|
|
if (typeof ev !== "object" || ev === null || Array.isArray(ev)) {
|
|
return { ok: false, reason: "invalid-evidence", detail: "evidence must be an object" };
|
|
}
|
|
const evKeys = Object.keys(ev);
|
|
if (evKeys.some((k) => !["journal", "journalEvents", "external"].includes(k))) {
|
|
return { ok: false, reason: "invalid-evidence", detail: "unknown evidence field" };
|
|
}
|
|
if (ev.journal !== true) {
|
|
return { ok: false, reason: "invalid-evidence", detail: "canary roles must journal (the only evidence sink)" };
|
|
}
|
|
if (ev.external !== "denied") {
|
|
return { ok: false, reason: "invalid-evidence", detail: "external evidence must be denied for canary roles" };
|
|
}
|
|
const REVIEWED_EVENTS = ["reconciliation", "denial", "improvement-proposal"];
|
|
if (!Array.isArray(ev.journalEvents) || ev.journalEvents.length !== REVIEWED_EVENTS.length) {
|
|
return { ok: false, reason: "invalid-journal-events", detail: `journalEvents must be exactly the reviewed set [${REVIEWED_EVENTS.join(", ")}]` };
|
|
}
|
|
const evSeen = new Set<string>();
|
|
for (const e of ev.journalEvents) {
|
|
if (typeof e !== "string" || !REVIEWED_EVENTS.includes(e)) {
|
|
return { ok: false, reason: "invalid-journal-events", detail: `unknown journal event "${String(e)}"` };
|
|
}
|
|
if (evSeen.has(e)) {
|
|
return { ok: false, reason: "invalid-journal-events", detail: `duplicate journal event "${e}"` };
|
|
}
|
|
evSeen.add(e);
|
|
}
|
|
|
|
// ---- digest/integrity rules: explicit and closed (F6) --------------------------
|
|
const dig = obj.digestRules;
|
|
if (typeof dig !== "object" || dig === null || Array.isArray(dig)) {
|
|
return { ok: false, reason: "invalid-digest-rules", detail: "digestRules must be an object" };
|
|
}
|
|
const digKeys = Object.keys(dig);
|
|
if (digKeys.some((k) => !["algorithm", "scope", "postcondition"].includes(k))) {
|
|
return { ok: false, reason: "invalid-digest-rules", detail: "unknown digestRules field" };
|
|
}
|
|
if (dig.algorithm !== "sha256") {
|
|
return { ok: false, reason: "invalid-digest-rules", detail: "digest algorithm must be sha256" };
|
|
}
|
|
if (dig.scope !== "manifest-bytes") {
|
|
return { ok: false, reason: "invalid-digest-rules", detail: "digest scope must be manifest-bytes" };
|
|
}
|
|
if (dig.postcondition !== "active-set-exact") {
|
|
return { ok: false, reason: "invalid-digest-rules", detail: "postcondition must be active-set-exact" };
|
|
}
|
|
|
|
// ---- forbiddenTools (auth): the reviewed absent set — these tools must
|
|
// never appear in the bound tools; overlap is a typed rejection
|
|
const forbidden = obj.forbiddenTools;
|
|
if (!Array.isArray(forbidden) || forbidden.length === 0 || forbidden.some((t) => typeof t !== "string" || !TOOL_RE.test(t))) {
|
|
return { ok: false, reason: "invalid-forbidden-tools", detail: "forbiddenTools must be a non-empty array of valid tool names" };
|
|
}
|
|
const forbiddenSet = new Set(forbidden as string[]);
|
|
if (forbiddenSet.size !== (forbidden as string[]).length) {
|
|
return { ok: false, reason: "invalid-forbidden-tools", detail: "duplicate entry in forbiddenTools" };
|
|
}
|
|
// NG4-CRIT: the manifest MUST contain the mandatory reviewed forbidden set
|
|
// (it may add more, never remove)
|
|
for (const m of MANDATORY_FORBIDDEN) {
|
|
if (!forbiddenSet.has(m)) {
|
|
return { ok: false, reason: "invalid-forbidden-tools", detail: `mandatory forbidden tool "${m}" is absent from forbiddenTools (cannot be removed)` };
|
|
}
|
|
}
|
|
for (const t of tools) {
|
|
if (forbiddenSet.has(t)) {
|
|
return { ok: false, reason: "forbidden-tool-overlap", detail: `tool "${t}" is both bound and forbidden` };
|
|
}
|
|
}
|
|
|
|
// ---- shell policy (auth): canary roles deny all shell access
|
|
const shell = obj.shell;
|
|
if (typeof shell !== "object" || shell === null || Array.isArray(shell)) {
|
|
return { ok: false, reason: "invalid-shell", detail: "shell must be an object" };
|
|
}
|
|
const shellKeys = Object.keys(shell);
|
|
if (shellKeys.some((k) => !["mode"].includes(k))) {
|
|
return { ok: false, reason: "invalid-shell", detail: "unknown shell field" };
|
|
}
|
|
if (shell.mode !== "denied") {
|
|
return { ok: false, reason: "invalid-shell", detail: "canary roles deny all shell access (shell.mode must be \"denied\")" };
|
|
}
|
|
|
|
// ---- C3 scope (F7): bound read capability requires declared scope ---------------
|
|
const c3 = capabilities.find((c) => c.id === "repo.file.read");
|
|
if (c3 && c3.status === "bound" && readRoots.length === 0) {
|
|
return { ok: false, reason: "invalid-workspace", detail: "C3 repo.file.read is bound but workspace.readRoots is empty — declare the read scope or leave C3 unbound" };
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
manifest: {
|
|
schemaVersion: 3,
|
|
role,
|
|
revision,
|
|
capabilities,
|
|
tools: [...tools],
|
|
extensions: { load: [...loadOrder] as string[] },
|
|
workspace: { readRoots: [...readRoots] as string[] },
|
|
credentials: { ...(creds as Record<string, unknown>) },
|
|
evidence: { journal: true, journalEvents: [...(ev as { journalEvents: string[] }).journalEvents], external: "denied" },
|
|
digestRules: { algorithm: "sha256", scope: "manifest-bytes", postcondition: "active-set-exact" },
|
|
forbiddenTools: [...forbidden] as string[],
|
|
shell: { mode: "denied" },
|
|
},
|
|
};
|
|
}
|
|
|
|
// ---- schema version 4: closed, role-class-specific authority -----------------
|
|
|
|
type V4Role = "gate-merge-ng" | "plan-ng" | "review-ng";
|
|
interface V4RoleSpec {
|
|
revision: 3 | 4;
|
|
capabilities: ReadonlyArray<Readonly<{ id: string; effect: CapabilityEffect; binding?: string; status: CapabilityStatus }>>;
|
|
tools: readonly string[];
|
|
extensions: readonly string[];
|
|
readRoots: readonly string[];
|
|
credentials: Record<string, unknown>;
|
|
evidence: Record<string, unknown>;
|
|
forbiddenTools: readonly string[];
|
|
targetPolicy?: Record<string, unknown>;
|
|
}
|
|
|
|
const GOAL_POLICY_FIELDS = [
|
|
"format", "contractPath", "contractSection", "contractBlob", "contractSha256", "enforcement", "identifierResolution",
|
|
] as const;
|
|
const GOAL_POLICY_VALUES: GoalReportPolicy = {
|
|
format: "ms-executive-update/v1",
|
|
contractPath: "skills-local/ms-executive-update/SKILL.md",
|
|
contractSection: "Machine contract (for `goal_report` payloads and any parser)",
|
|
contractBlob: "df30c6fbb54b4a65a298c9e51c07f742610d171c",
|
|
contractSha256: "bbea48a46b1f8da7bc759f86856fb52830b7dde456b826317163c6dc6ccab319",
|
|
enforcement: "pre-state-change-fail-closed",
|
|
identifierResolution: "consumer-fail-closed",
|
|
};
|
|
|
|
const V4_COMMON_POLICY = { algorithm: "sha256", scope: "manifest-bytes", postcondition: "active-set-exact" } as const;
|
|
const PLAN_REVIEW_CAPABILITIES = [
|
|
{ id: "policy.status", effect: "observe", binding: "mosaic_policy_status", status: "bound" },
|
|
{ id: "improvement.propose", effect: "propose", binding: "mosaic_improvement_propose", status: "bound" },
|
|
{ id: "repo.file.read", effect: "observe", binding: "read", status: "bound" },
|
|
{ id: "goal.report", effect: "transition", binding: "goal_report", status: "bound" },
|
|
{ id: "coord.route_implementation", effect: "transition", status: "unbound" },
|
|
{ id: "comms.send", effect: "communicate", status: "unbound" },
|
|
] as const;
|
|
const GATE_CAPABILITIES = [
|
|
{ id: "policy.status", effect: "observe", binding: "mosaic_policy_status", status: "bound" },
|
|
{ id: "goal.report", effect: "transition", binding: "goal_report", status: "bound" },
|
|
{ id: "repo.target.inspect", effect: "observe", binding: "mosaic_gate_target_read", status: "bound" },
|
|
{ id: "gate.evidence.observe", effect: "observe", binding: "mosaic_gate_evidence_observe", status: "bound" },
|
|
{ id: "gate.verdict", effect: "transition", binding: "mosaic_gate_verdict", status: "bound" },
|
|
] as const;
|
|
const PLAN_REVIEW_FORBIDDEN = ["bash", "edit", "git", "powershell", "send_message", "tmux", "write"] as const;
|
|
const GATE_FORBIDDEN = [
|
|
"bash", "browser", "edit", "fetch", "find", "git", "grep", "http", "ls", "mosaic_improvement_propose",
|
|
"powershell", "pr_merge", "read", "send_message", "tmux", "web", "write",
|
|
] as const;
|
|
|
|
const V4_SPECS: Record<V4Role, V4RoleSpec> = {
|
|
"gate-merge-ng": {
|
|
revision: 3,
|
|
capabilities: GATE_CAPABILITIES,
|
|
tools: ["goal_report", "mosaic_gate_evidence_observe", "mosaic_gate_target_read", "mosaic_gate_verdict", "mosaic_policy_status"],
|
|
extensions: ["goal", "gate-merge", "mosaic-core"],
|
|
readRoots: [],
|
|
credentials: { seatStore: "none", helper: "none", providerTokens: "denied", brokerChannel: "launcher-inherited-attested-fd" },
|
|
evidence: { localJournal: true, journalEvents: ["reconciliation", "denial", "gate-observation", "gate-verdict-attempt"], canonicalWriter: "mosaic-gate-broker", external: "gate-evidence-and-verdict-only" },
|
|
forbiddenTools: GATE_FORBIDDEN,
|
|
targetPolicy: { kind: "pull-request", hosts: ["git.mosaicstack.dev"], repositories: ["mosaicstack/stack"], pinSource: "assignment" },
|
|
},
|
|
"plan-ng": {
|
|
revision: 4,
|
|
capabilities: PLAN_REVIEW_CAPABILITIES,
|
|
tools: ["goal_report", "mosaic_improvement_propose", "mosaic_policy_status", "read"],
|
|
extensions: ["goal", "mosaic-core"],
|
|
readRoots: ["@brain"],
|
|
credentials: { store: "none", helper: "none", providerTokens: "denied" },
|
|
evidence: { journal: true, journalEvents: ["reconciliation", "denial", "improvement-proposal"], external: "denied" },
|
|
forbiddenTools: PLAN_REVIEW_FORBIDDEN,
|
|
},
|
|
"review-ng": {
|
|
revision: 4,
|
|
capabilities: PLAN_REVIEW_CAPABILITIES,
|
|
tools: ["goal_report", "mosaic_improvement_propose", "mosaic_policy_status", "read"],
|
|
extensions: ["goal", "mosaic-core"],
|
|
readRoots: ["@brain"],
|
|
credentials: { store: "none", helper: "none", providerTokens: "denied" },
|
|
evidence: { journal: true, journalEvents: ["reconciliation", "denial", "improvement-proposal"], external: "denied" },
|
|
forbiddenTools: PLAN_REVIEW_FORBIDDEN,
|
|
},
|
|
};
|
|
|
|
function exactObject(value: unknown, expected: Record<string, unknown>): boolean {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
const obj = value as Record<string, unknown>;
|
|
const keys = Object.keys(obj).sort();
|
|
const expectedKeys = Object.keys(expected).sort();
|
|
if (keys.length !== expectedKeys.length || keys.some((key, index) => key !== expectedKeys[index])) return false;
|
|
return expectedKeys.every((key) => exactValue(obj[key], expected[key]));
|
|
}
|
|
|
|
function exactValue(actual: unknown, expected: unknown): boolean {
|
|
if (Array.isArray(expected)) {
|
|
return Array.isArray(actual) && actual.length === expected.length && actual.every((entry, index) => exactValue(entry, expected[index]));
|
|
}
|
|
if (typeof expected === "object" && expected !== null) return exactObject(actual, expected as Record<string, unknown>);
|
|
return actual === expected;
|
|
}
|
|
|
|
function sameStringSet(value: unknown, expected: readonly string[]): boolean {
|
|
return Array.isArray(value)
|
|
&& value.every((entry) => typeof entry === "string")
|
|
&& new Set(value).size === value.length
|
|
&& value.length === expected.length
|
|
&& [...value].sort().every((entry, index) => entry === [...expected].sort()[index]);
|
|
}
|
|
|
|
function exactCapabilitySet(value: unknown, expected: V4RoleSpec["capabilities"]): boolean {
|
|
if (!Array.isArray(value) || value.length !== expected.length) return false;
|
|
const byId = new Map<string, Record<string, unknown>>();
|
|
for (const raw of value) {
|
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return false;
|
|
const cap = raw as Record<string, unknown>;
|
|
if (typeof cap.id !== "string" || byId.has(cap.id)) return false;
|
|
byId.set(cap.id, cap);
|
|
}
|
|
return expected.every((definition) => {
|
|
const cap = byId.get(definition.id);
|
|
if (!cap) return false;
|
|
const expectedKeys = definition.status === "bound" ? ["id", "effect", "binding", "status"] : ["id", "effect", "status"];
|
|
if (Object.keys(cap).length !== expectedKeys.length || Object.keys(cap).some((key) => !expectedKeys.includes(key))) return false;
|
|
const normative = NORMATIVE_BINDINGS[definition.id];
|
|
return !!normative
|
|
&& definition.effect === cap.effect
|
|
&& definition.status === cap.status
|
|
&& cap.effect === (definition.effect as string)
|
|
&& cap.status === definition.status
|
|
&& (definition.status === "unbound" || ("binding" in definition && cap.binding === definition.binding && normative.status === "bound" && normative.binding === definition.binding));
|
|
});
|
|
}
|
|
|
|
function validateV4Manifest(obj: Record<string, unknown>): PolicyResult {
|
|
const role = obj.role;
|
|
if (role !== "gate-merge-ng" && role !== "plan-ng" && role !== "review-ng") {
|
|
return { ok: false, reason: "invalid-role-class", detail: "schema version 4 supports only gate-merge-ng, plan-ng, and review-ng" };
|
|
}
|
|
const spec = V4_SPECS[role];
|
|
const rootFields = [
|
|
"schemaVersion", "role", "revision", "capabilities", "tools", "extensions", "workspace", "credentials", "evidence",
|
|
"digestRules", "forbiddenTools", "shell", "goalReportPolicy", ...(spec.targetPolicy ? ["targetPolicy"] : []),
|
|
];
|
|
if (Object.keys(obj).length !== rootFields.length || Object.keys(obj).some((key) => !rootFields.includes(key))) {
|
|
return { ok: false, reason: "unknown-field", detail: "schema version 4 manifest fields must be exact" };
|
|
}
|
|
if (obj.schemaVersion !== 4) return { ok: false, reason: "invalid-schema-version", detail: "schemaVersion must be 4" };
|
|
if (obj.revision !== spec.revision) return { ok: false, reason: "invalid-revision", detail: `${role} must have revision ${spec.revision}` };
|
|
if (!exactCapabilitySet(obj.capabilities, spec.capabilities)) return { ok: false, reason: "version4-exact-set-mismatch", detail: "capabilities must be the exact reviewed role-class set" };
|
|
if (!sameStringSet(obj.tools, spec.tools)) return { ok: false, reason: "tools-bindings-mismatch", detail: "tools must be the exact reviewed bound capability projection" };
|
|
if (!exactObject(obj.extensions, { load: spec.extensions })) return { ok: false, reason: "invalid-extensions", detail: "extensions.load must be the exact reviewed order" };
|
|
if (!exactObject(obj.workspace, { readRoots: spec.readRoots })) return { ok: false, reason: "invalid-workspace", detail: "workspace.readRoots must be exact for the role class" };
|
|
if (!exactObject(obj.credentials, spec.credentials)) return { ok: false, reason: "invalid-credentials", detail: "credentials policy must be exact" };
|
|
if (!exactObject(obj.evidence, spec.evidence)) return { ok: false, reason: "invalid-evidence", detail: "evidence policy must be exact" };
|
|
if (!exactObject(obj.digestRules, V4_COMMON_POLICY)) return { ok: false, reason: "invalid-digest-rules", detail: "digest rules must be exact" };
|
|
if (!sameStringSet(obj.forbiddenTools, spec.forbiddenTools)) return { ok: false, reason: "invalid-forbidden-tools", detail: "forbidden tools must be the exact reviewed set" };
|
|
if (!exactObject(obj.shell, { mode: "denied" })) return { ok: false, reason: "invalid-shell", detail: "shell.mode must be denied" };
|
|
if (!exactObject(obj.goalReportPolicy, GOAL_POLICY_VALUES)) return { ok: false, reason: "invalid-goal-report-policy", detail: "goalReportPolicy must pin the reviewed contract exactly" };
|
|
if (spec.targetPolicy) {
|
|
if (!exactObject(obj.targetPolicy, spec.targetPolicy)) return { ok: false, reason: "invalid-target-policy", detail: "targetPolicy must pin the reviewed assignment scope" };
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
manifest: {
|
|
schemaVersion: 4,
|
|
role,
|
|
revision: spec.revision,
|
|
capabilities: (obj.capabilities as CapabilityBinding[]).map((capability) => ({ ...capability })),
|
|
tools: [...obj.tools as string[]],
|
|
extensions: { load: [...(obj.extensions as { load: string[] }).load] },
|
|
workspace: { readRoots: [...(obj.workspace as { readRoots: string[] }).readRoots] },
|
|
credentials: { ...(obj.credentials as Record<string, unknown>) },
|
|
evidence: { ...(obj.evidence as Record<string, unknown>) },
|
|
digestRules: { ...V4_COMMON_POLICY },
|
|
forbiddenTools: [...obj.forbiddenTools as string[]],
|
|
shell: { mode: "denied" },
|
|
goalReportPolicy: { ...GOAL_POLICY_VALUES },
|
|
...(spec.targetPolicy ? { targetPolicy: { ...(spec.targetPolicy as { kind: "pull-request"; hosts: string[]; repositories: string[]; pinSource: "assignment" }) } } : {}),
|
|
},
|
|
};
|
|
}
|