feat(extensions): establish canonical goal source (#54, #55)

This commit is contained in:
2026-09-06 02:32:32 -05:00
parent 44f257cb06
commit d4696d09eb
43 changed files with 6845 additions and 0 deletions
+314
View File
@@ -0,0 +1,314 @@
// lib/adapter.ts — Pi adapter binding (PRD R3-R5; NG-2).
//
// Deliberately dependency-light (type-only imports + node builtins) so the
// hermetic suite can import and exercise the REAL binding against a fake pi
// — including the AC4 red control, which sabotages this file's enforcement
// registration block and proves the same call then passes.
//
// The binding:
// session_start (every reason: startup/new/resume/reload/fork — policy may
// have changed on disk between sessions) ->
// resolve seat role from the launcher-established agent
// name -> load trusted manifest -> build application ->
// setActiveTools(exact) -> VERIFY getActiveTools() equals
// the exact set; any drift at any stage -> fail-closed.
// tool_call -> decideToolCall; blocked calls return the stable code.
import { incarnationIdentity } from "./incarnation.ts";
import {
buildApplication,
decideToolCall,
failClosedState,
statusSnapshot,
STATUS_TOOL,
PROPOSAL_TOOL,
SAFE_READ_TOOLS,
type EnforcementState,
} from "./enforce.ts";
import { loadTrustedManifest, seatRole, type LoadOutcome, type LoaderIO } from "./loader.ts";
import { createJournal, type Journal, type JournalIO } from "./journal.ts";
import { sanitizeProposal } from "./proposal.ts";
import { publishGoalPolicy, verifyGoalPolicyPublication, type GoalPolicyAttestationV1 } from "./goal-policy.ts";
import { readFileSync, lstatSync, realpathSync } from "node:fs";
import { homedir } from "node:os";
export interface PiToolCallEvent {
toolName: string;
input?: unknown;
}
export interface PiLike {
on(event: "session_start", handler: (event: { reason: string }) => void | Promise<void>): void;
on(event: "tool_call", handler: (event: PiToolCallEvent) => unknown): void;
getActiveTools(): string[];
setActiveTools(names: string[]): void;
registerTool(tool: Record<string, unknown>): void;
}
export interface AdapterOptions {
env?: Record<string, string | undefined>;
io?: LoaderIO;
journalIO?: JournalIO;
stateHome?: string;
incarnationId?: string;
launchGeneration?: number;
}
function defaultIO(): LoaderIO {
return {
readFileSync(path: string): string | Uint8Array { return readFileSync(path); },
lstatSync,
realpathSync,
};
}
function launchGeneration(env: Record<string, string | undefined>, supplied: number | undefined): number | undefined {
if (supplied !== undefined) return supplied;
const raw = env.MOSAIC_LAUNCH_GENERATION;
if (!raw || !/^[1-9][0-9]*$/.test(raw)) return undefined;
const value = Number(raw);
return Number.isSafeInteger(value) ? value : undefined;
}
export function bindEnforcement(pi: PiLike, opts: AdapterOptions = {}): void {
const env = opts.env ?? process.env;
const io = opts.io ?? defaultIO();
const incarnationId = opts.incarnationId ?? incarnationIdentity();
const configuredLaunchGeneration = launchGeneration(env, opts.launchGeneration);
let enforcement: EnforcementState = { state: "not-applied", incarnationId };
let activeGoalPolicy: GoalPolicyAttestationV1 | undefined;
// A version-4 role has exactly one trusted manifest/contract load per
// incarnation. Reloads reuse this immutable result and never republish.
let v4Load: Extract<LoadOutcome, { ok: true }> | undefined;
let v4Published = false;
const brainHome = env.MOSAIC_BRAIN_HOME || `${homedir()}/.mosaic`;
const journal: Journal = createJournal({
incarnationId,
stateHome: opts.stateHome ?? env.XDG_STATE_HOME,
io: opts.journalIO,
});
pi.registerTool({
name: STATUS_TOOL,
label: "Mosaic policy status",
description:
"Read-only report of the applied mosaic-core role policy: role, revision, digest, " +
"active tools, and enforcement state. Cannot modify anything.",
parameters: { type: "object", properties: {}, additionalProperties: false },
async execute() {
return {
content: [{ type: "text", text: JSON.stringify(statusSnapshot(enforcement, pi.getActiveTools())) }],
details: {},
};
},
});
pi.registerTool({
name: PROPOSAL_TOOL,
label: "Mosaic improvement proposal",
description:
"Record a structured improvement proposal for the mosaic-core role policy. Proposals " +
"are appended to the per-incarnation journal for coordinator review; they never " +
"modify manifests, settings, or active tools.",
parameters: {
type: "object",
properties: {
target: { type: "string", description: "What the proposal concerns: a role, tool, or policy field" },
summary: { type: "string", description: "One line, at most 500 characters" },
motivation: { type: "string", description: "Why this improvement matters, at most 2000 characters" },
evidence: { type: "array", items: { type: "string" }, description: "Optional evidence pointers, at most 8 items of 500 characters" },
},
required: ["target", "summary", "motivation"],
additionalProperties: false,
},
async execute(_id: string, params: unknown) {
const verdict = sanitizeProposal(params);
if (!verdict.ok) {
return {
content: [{ type: "text", text: `proposal rejected (${verdict.reason}${verdict.detail ? `: ${verdict.detail}` : ""}) — recorded nothing` }],
details: { rejected: verdict.reason },
};
}
try {
journal.append({ kind: "improvement-proposal", proposal: verdict.proposal });
} catch {
// Journal failure: the proposal is NOT recorded, nothing is applied,
// and the caller sees a fixed message with no raw exception
return {
content: [{ type: "text", text: "proposal recording unavailable (journal failure); recorded nothing; nothing applied" }],
details: { recorded: false, cause: "journal-failure" },
};
}
return {
content: [{ type: "text", text: `proposal recorded for coordinator review (target: ${verdict.proposal.target}); nothing was applied` }],
details: { recorded: true },
};
},
});
// [NG-2 enforcement binding begin]
// The enforcement surface under AC4's removal red control: exactly these
// two registrations. Sabotage that deletes this block leaves the status
// tool but no interception and no application — the same call then passes.
pi.on("session_start", async () => {
// Resolve the profile role before entering degraded mode. A known gate role
// gets status only if its manifest, contract, resolver, or publication
// fails; it must not inherit generic safe reads or proposal authority.
const profileRole = seatRole(brainHome, env.MOSAIC_AGENT_NAME, io);
let roleHint = profileRole ?? undefined;
// NG4-RECON: enter TRUSTED FAIL-CLOSED BEFORE any platform call.
// If setActiveTools or getActiveTools throws mid-reconciliation, the
// previous applied policy does NOT remain authoritative (R5).
enforcement = failClosedState(incarnationId, "reconciliation-in-progress", brainHome, roleHint);
try {
let load: LoadOutcome;
activeGoalPolicy = undefined;
if (v4Load) {
load = v4Load;
roleHint = v4Load.manifest.role;
} else {
if (profileRole === null) {
const stage = env.MOSAIC_AGENT_NAME ? "no-seat-profile" : "no-agent-name";
load = { ok: false, failure: { stage } as LoadOutcome extends { ok: false; failure: infer F } ? F : never };
} else {
load = loadTrustedManifest(brainHome, profileRole, io, { incarnationId, launchGeneration: configuredLaunchGeneration });
if (load.ok) roleHint = load.manifest.role;
}
if (load.ok && load.manifest.schemaVersion === 4) v4Load = load;
}
if (load.ok && load.manifest.schemaVersion === 4) {
const policy = load.goalPolicy?.attestation;
if (!policy) {
load = { ok: false, failure: { stage: "goal-policy-publication" } };
} else if (!v4Published) {
const publication = publishGoalPolicy(policy, (reason) => {
try {
journal.append({ kind: "denial", tool: "goal_report", reason: `mosaic-core:goal-policy-denied:${reason}` });
} catch {
console.warn("[mosaic-core] journal append failed (enforcement unaffected; details suppressed)");
}
});
if (!publication.ok) {
load = { ok: false, failure: { stage: "goal-policy-publication" } };
} else {
v4Published = true;
activeGoalPolicy = publication.attestation;
}
} else {
activeGoalPolicy = policy;
}
}
const application = buildApplication({
load,
activeTools: pi.getActiveTools(),
incarnationId,
roleHint,
brainHome,
});
pi.setActiveTools(application.applyTools);
const got = [...pi.getActiveTools()].sort();
const want = [...application.applyTools].sort();
if (JSON.stringify(got) !== JSON.stringify(want)) {
enforcement = failClosedState(incarnationId, "postcondition-failed", brainHome, roleHint);
// A mismatch is not merely a status value. Remove the broad/incorrect
// active projection before returning; interception remains the final
// denial if the platform rejects this recovery call too.
try { pi.setActiveTools([...enforcement.allowed].sort()); } catch { /* interception governs */ }
try { journal.append({ kind: "reconciliation", state: "fail-closed", cause: "postcondition-failed", removed: [], added: [] }); } catch (e) { console.warn("[mosaic-core] journal append failed (enforcement unaffected; details suppressed)"); }
return;
}
enforcement = application.state;
try {
journal.append({
kind: "reconciliation",
state: application.state.state,
role: application.state.state === "applied" ? application.state.role : undefined,
revision: application.state.state === "applied" ? application.state.revision : undefined,
digest: application.state.state === "applied" ? application.state.digest : undefined,
cause: application.state.state === "fail-closed" ? application.state.cause : undefined,
removed: application.plan ? application.plan.toRemove : [],
added: application.plan ? application.plan.toAdd : [],
});
} catch (e) { console.warn("[mosaic-core] journal append failed (enforcement unaffected; details suppressed)"); }
} catch (reconErr) {
// Platform call threw mid-reconciliation: the provisional fail-closed
// state GOVERNS — the old applied policy is dead.
// NG4-CAUSE: STABLE cause only — never embed the raw exception.
// status EXPOSES cause; an injected/platform error could carry
// sensitive content into model-visible denial reasons.
enforcement = failClosedState(incarnationId, "reconciliation-threw", brainHome, roleHint);
try { pi.setActiveTools([...enforcement.allowed].sort()); } catch { /* best-effort; interception governs */ }
try { journal.append({ kind: "reconciliation", state: "fail-closed", cause: "reconciliation-threw", removed: [], added: [] }); } catch { /* evidence never bypasses enforcement */ }
}
});
pi.on("tool_call", async (event: PiToolCallEvent) => {
// F7-(1): wire event.input through so the read-containment check sees
// the path argument. F7-B precision: for read-family tools (read,
// grep, find, ls) with an ABSENT path, Pi executes against the tool's
// own cwd — an uncontrolled location. We MUTATE the input to pin the
// path to the trusted brain home (Pi guarantees mutation affects
// execution and is not revalidated). The test suite asserts the
// executed input IS pinned, not just the decision result.
// F7E: read.path is REQUIRED in Pi — absent/malformed input is DENIED
// by the enforcement layer (no synthesis, no cwd ambiguity). Only ls
// gets absent-path pinning (it lists a directory, not a file, and its
// path is genuinely optional in Pi).
if ((SAFE_READ_TOOLS as readonly string[]).includes(event.toolName)) {
// (2) ls with UNDEFINED input: assign {path: brainHome} (safe dir listing)
// FIX2-(1): synthesize ONLY when event.input is EXACTLY undefined.
// null / 42 / [] are malformed and must reach stable DENIAL, not be
// silently mutated into a valid input.
if (event.toolName === "ls" && event.input === undefined) {
event.input = { path: brainHome };
}
if (event.input && typeof event.input === "object") {
const input = event.input as { path?: unknown };
// ls with absent path in an existing object: pin to brainHome
if (event.toolName === "ls" && (input.path === undefined || input.path === "")) {
input.path = brainHome;
}
// (3) Resolve RELATIVE paths against brainHome BEFORE realpath
if (typeof input.path === "string" && input.path !== "" && !input.path.startsWith("/")) {
input.path = brainHome + "/" + input.path;
}
// F7C-(1): realpath existing targets — symlink escape resolution
if (typeof input.path === "string" && input.path !== "") {
try {
input.path = realpathSync(input.path);
} catch {
// path doesn't exist yet — leave as-is; containment handles traversal
}
}
}
}
if (event.toolName === "goal_report" && activeGoalPolicy) {
const policy = verifyGoalPolicyPublication(activeGoalPolicy);
if (!policy.ok) {
const reason = `mosaic-core:goal-policy-denied:${policy.reason}`;
try {
journal.append({ kind: "denial", tool: event.toolName, reason });
} catch {
console.warn("[mosaic-core] journal append failed (enforcement unaffected; details suppressed)");
}
return { block: true, reason };
}
}
const decision = decideToolCall(enforcement, event.toolName, event.input, brainHome);
if (decision.block) {
// PART B: evidence failure must NEVER bypass enforcement — the denial
// returns even if the journal append throws (disk full, permission,
// injected failure)
try {
journal.append({ kind: "denial", tool: event.toolName, reason: decision.reason });
} catch (journalErr) {
console.warn("[mosaic-core] journal append failed (enforcement unaffected; details suppressed)");
}
return { block: true, reason: decision.reason };
}
return undefined;
});
// [NG-2 enforcement binding end]
}
+372
View File
@@ -0,0 +1,372 @@
// lib/enforce.ts — pure enforcement state machine (PRD R3-R5, AC3/AC5/AC6).
//
// Two states, one decision function:
// applied — a valid trusted manifest governs; allowed = the exact
// manifest tool projection. Registered extension tools that
// lack a bound capability remain unavailable.
// fail-closed — load/validation/postcondition failed; allowed = SAFE_READ_TOOLS
// + the status tool ONLY (AC5: safe reads and status survive;
// every side-effecting tool blocks with a stable reason).
//
// Reason codes are STABLE STRINGS (AC3) — they appear in logs, journals, and
// tests, so the format is part of the contract:
// mosaic-core:tool-not-in-manifest:<tool>
// mosaic-core:fail-closed:<cause>:<tool>
// mosaic-core:not-applied:<tool>
// mosaic-core:read-missing-path
// mosaic-core:read-scope-unbound
// mosaic-core:read-credential-denied:<canonical-path>
// mosaic-core:read-outside-scope:<canonical-path>
// mosaic-core:read-symlink-escape:<canonical-path>
import type { ReconcilePlan } from "./reconcile.ts";
import { reconcileActiveTools } from "./reconcile.ts";
import type { LoadOutcome } from "./loader.ts";
export const STATUS_TOOL = "mosaic_policy_status";
export const PROPOSAL_TOOL = "mosaic_improvement_propose";
export const GATE_MERGE_ROLE = "gate-merge-ng";
/**
* F7D (mercer): only DIRECTLY-CONSTRAINABLE read tools survive fail-closed.
* `read` reads exactly one file (path argument IS the target); `ls` lists one
* directory non-recursively. `grep` and `find` RECURSE below the path root —
* they can enumerate and expose credential-shaped descendants
* (brainHome/auth, fleet/agents/<seat>/secrets, junk) even when the root path
* passes containment. gitignore is NOT an authorization boundary. Applied
* manifests already bind only C3->read; exposing grep/find in degraded
* state INCREASES authority. AC5 says safe reads, not every read-family tool.
*/
export const SAFE_READ_TOOLS = ["read", "ls"] as const;
export const SYMBOLIC_BRAIN = "@brain";
export type EnforcementState =
| {
state: "applied";
incarnationId: string;
role: string;
revision: number;
digest: string;
allowed: ReadonlySet<string>;
/** C3 read-scope roots. Symbolic "@brain" or absolute. Empty = C3 unbound. */
readScope: readonly string[];
}
| {
state: "fail-closed";
incarnationId: string;
cause: string;
allowed: ReadonlySet<string>;
readScope: readonly string[];
}
| { state: "not-applied"; incarnationId: string };
export interface PolicyApplicationInput {
load: LoadOutcome;
activeTools: string[];
incarnationId: string;
/** Role resolved from the seat profile before a v4 load can fail. */
roleHint?: string;
/** The trusted brain home (for resolving the symbolic @brain root). */
brainHome?: string;
}
export interface PolicyApplication {
state: EnforcementState;
plan?: ReconcilePlan;
/** the exact tool set setActiveTools must receive */
applyTools: string[];
}
/**
* A gate role's reviewed manifest explicitly forbids read, ls, and proposal.
* On any gate load or reconciliation failure, retaining the generic safe-read
* posture would broaden that role's active authority. Other roles retain the
* established v3 degraded posture for compatibility.
*/
export function failClosedState(
incarnationId: string,
cause: string,
brainHome?: string,
roleHint?: string,
): Extract<EnforcementState, { state: "fail-closed" }> {
const gateRole = roleHint === GATE_MERGE_ROLE;
const allowed = gateRole
? new Set([STATUS_TOOL])
: new Set([...SAFE_READ_TOOLS, STATUS_TOOL, PROPOSAL_TOOL]);
return {
state: "fail-closed",
incarnationId,
cause,
allowed,
readScope: gateRole ? [] : brainHome ? [brainHome] : [],
};
}
export function buildApplication(input: PolicyApplicationInput): PolicyApplication {
const { load, activeTools, incarnationId, brainHome, roleHint } = input;
if (!load.ok) {
const state = failClosedState(incarnationId, describeFailure(load.failure), brainHome, roleHint);
return { state, applyTools: [...state.allowed].sort() };
}
const m = load.manifest;
const allowed = new Set(m.tools);
const plan = reconcileActiveTools(m.tools, activeTools);
const c3 = m.capabilities.find((c) => c.id === "repo.file.read");
const rawScope = c3 && c3.status === "bound" ? m.workspace.readRoots : [];
const readScope = resolveReadScope(rawScope, brainHome);
return {
state: {
state: "applied",
incarnationId,
role: m.role,
revision: m.revision,
digest: load.digest,
allowed,
readScope,
},
plan,
applyTools: [...allowed].sort(),
};
}
/**
* F7-(4): resolve the symbolic "@brain" root to the trusted brain home;
* absolute roots pass through unchanged.
*/
export function resolveReadScope(roots: readonly string[], brainHome?: string): string[] {
return roots.map((r) => (r === SYMBOLIC_BRAIN && brainHome ? brainHome : r));
}
export function describeFailure(f: import("./loader.ts").LoadFailure): string {
switch (f.stage) {
case "no-agent-name":
return "no-agent-name";
case "no-seat-profile":
return "no-seat-profile";
case "no-role-in-profile":
return "no-role-in-profile";
case "manifest-unreadable":
return "manifest-unreadable";
case "manifest-symlink":
return "manifest-symlink";
case "invalid-json":
return "invalid-json";
case "schema":
return `schema:${f.reason}`;
case "role-mismatch":
return `role-mismatch:${f.expected}!=${f.got}`;
case "goal-policy-unreadable":
case "goal-policy-root-symlink":
case "goal-policy-parent-symlink":
case "goal-policy-symlink":
case "goal-policy-path-escape":
case "goal-policy-non-file":
case "goal-policy-invalid-utf8":
case "goal-policy-digest":
case "goal-policy-section":
case "goal-policy-launch-generation":
case "goal-policy-incarnation":
case "goal-policy-publication":
return f.stage;
}
}
export interface Decision {
block: boolean;
reason?: string;
}
/**
* F7-(5): credential deny pattern — covers secrets/ and auth/ directories,
* .token/.key/.pem/.env* file types, and any path containing "credential".
* Applied AFTER canonical resolution so traversal cannot bypass it.
*/
const CREDENTIAL_PATH_RE = /(?:^|\/)(?:secrets?|auth)(?:\/|$)/i;
const CREDENTIAL_FILE_RE = /\.(?:token|key|pem|env)(?:\.|$)|credential|fleet\/agents\/junk/i;
const READ_TOOL_BINDING = "read"; // canonical Pi read tool
function isCredentialPath(canonical: string): boolean {
return CREDENTIAL_PATH_RE.test(canonical) || CREDENTIAL_FILE_RE.test(canonical);
}
/**
* F7-(2): canonicalize a path — resolve `..` and `.` segments lexically,
* resolve relative paths against the trusted root. The ADAPTER must
* additionally realpath() existing targets to catch symlink escapes
* before calling this function (see decideToolCallWithFs).
*/
export function canonicalizePath(rawPath: string, relativeRoot?: string): string {
let p = rawPath;
// resolve relative against the trusted root (never cwd)
if (!p.startsWith("/") && relativeRoot) {
p = relativeRoot + "/" + p;
}
const parts: string[] = [];
for (const seg of p.split("/")) {
if (seg === "" || seg === ".") continue;
if (seg === "..") { parts.pop(); continue; }
parts.push(seg);
}
return "/" + parts.join("/");
}
/**
* F7-(2,5): read containment with credential denial AFTER canonical resolution.
* readScope roots must themselves be canonical absolute paths.
* Returns ok only if the canonical path falls under a declared root AND
* does not match the credential deny pattern post-resolution.
*/
export function readPathAllowed(
readScope: readonly string[],
rawPath: string,
relativeRoot?: string,
): { ok: boolean; reason?: string } {
if (readScope.length === 0) return { ok: false, reason: "mosaic-core:read-scope-unbound" };
// F7-(2,3): missing, empty, or NON-STRING path is DENIED, not passed through
if (typeof rawPath !== "string" || rawPath === "") {
return { ok: false, reason: "mosaic-core:read-missing-path" };
}
const canonical = canonicalizePath(rawPath, relativeRoot);
// F7-(5): credential denial AFTER canonical resolution
if (isCredentialPath(canonical)) {
return { ok: false, reason: `mosaic-core:read-credential-denied:${canonical}` };
}
for (const root of readScope) {
if (canonical === root || canonical.startsWith(root + "/")) {
return { ok: true };
}
}
return { ok: false, reason: `mosaic-core:read-outside-scope:${canonical}` };
}
/**
* F7-(3): safe-read constraint — the SAFE_READ_TOOLS that survive
* fail-closed must still respect the credential deny pattern.
* Without this, a malformed manifest would EXPOSE broader reads than an
* applied role. Safe reads get NO workspace scope (scope is empty in
* fail-closed/not-applied), so read is denied; grep/glob/ls still have
* credential-shaped path denial applied to their arguments.
*/
/**
* F7-B (mercer NG4-F7B): all four Pi safe-read built-ins (read, grep, find,
* ls) take an optional path argument; absent path defaults to the tool's
* cwd. This function treats ABSENT path as the trusted root (in scope by
* definition) and a PROVIDED path through canonical containment + credential
* denial. In fail-closed, the scope IS the trusted brain home so reads
* within the brain tree survive; reads outside are denied.
*/
export function safeReadAllowed(
toolName: string,
readScope: readonly string[],
args?: unknown,
trustedRoot?: string,
): { ok: boolean; reason?: string } {
if (toolName === STATUS_TOOL || toolName === PROPOSAL_TOOL) return { ok: true };
// F7F: tool-specific input shape — read REQUIRES a non-array object with
// a nonempty string path; ls may accept undefined/{} but non-object/null/
// array or non-string path DENIES. The pure decision must align with the
// adapter (which synthesizes ls input ONLY when undefined).
const isObject = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
if (toolName === READ_TOOL_BINDING) {
// read: args must be a non-array object with nonempty string path
if (!isObject(args)) {
return { ok: false, reason: "mosaic-core:read-missing-path" };
}
const path = args.path;
if (typeof path !== "string" || path === "") {
return { ok: false, reason: "mosaic-core:read-missing-path" };
}
// containment + credential denial
const effectiveScope = readScope.length > 0 ? readScope : trustedRoot ? [trustedRoot] : [];
const canonical = canonicalizePath(path, trustedRoot);
if (isCredentialPath(canonical)) {
return { ok: false, reason: `mosaic-core:read-credential-denied:${canonical}` };
}
if (effectiveScope.length === 0) {
return { ok: false, reason: "mosaic-core:read-scope-unbound" };
}
for (const root of effectiveScope) {
if (canonical === root || canonical.startsWith(root + "/")) return { ok: true };
}
return { ok: false, reason: `mosaic-core:read-outside-scope:${canonical}` };
}
// ls: undefined args OK (adapter synthesizes); non-object/null/array DENIES
if (args !== undefined && !isObject(args)) {
return { ok: false, reason: "mosaic-core:read-missing-path" };
}
const path = isObject(args) ? args.path : undefined;
if (path !== undefined && typeof path !== "string") {
return { ok: false, reason: "mosaic-core:read-missing-path" };
}
// credential check on the resolved path (or brainHome for absent)
const effectiveScope = readScope.length > 0 ? readScope : trustedRoot ? [trustedRoot] : [];
const effectivePath = typeof path === "string" && path !== "" ? path : (trustedRoot ?? "/");
const canonical = canonicalizePath(effectivePath, trustedRoot);
if (isCredentialPath(canonical)) {
return { ok: false, reason: `mosaic-core:read-credential-denied:${canonical}` };
}
if (effectiveScope.length === 0) {
return { ok: false, reason: "mosaic-core:read-scope-unbound" };
}
for (const root of effectiveScope) {
if (canonical === root || canonical.startsWith(root + "/")) return { ok: true };
}
return { ok: false, reason: `mosaic-core:read-outside-scope:${canonical}` };
}
export function decideToolCall(state: EnforcementState, toolName: string, args?: unknown, relativeRoot?: string): Decision {
switch (state.state) {
case "not-applied":
if (toolName === STATUS_TOOL || toolName === PROPOSAL_TOOL || (SAFE_READ_TOOLS as readonly string[]).includes(toolName)) {
const safe = safeReadAllowed(toolName, [], args, relativeRoot);
if (!safe.ok) return { block: true, reason: safe.reason };
return { block: false };
}
return { block: true, reason: `mosaic-core:not-applied:${toolName}` };
case "fail-closed":
if (state.allowed.has(toolName)) {
const safe = safeReadAllowed(toolName, state.readScope ?? [], args, relativeRoot);
if (!safe.ok) return { block: true, reason: safe.reason };
return { block: false };
}
return { block: true, reason: `mosaic-core:fail-closed:${state.cause}:${toolName}` };
case "applied":
if (state.allowed.has(toolName)) {
// F7-(2): C3 read containment. Missing path DENIED. Relative paths
// resolved against the trusted brain root. The adapter must
// additionally realpath() existing targets before deciding.
if (toolName === READ_TOOL_BINDING && state.readScope.length > 0) {
const path = args && typeof args === "object" ? (args as { path?: unknown }).path : undefined;
const scope = readPathAllowed(state.readScope, typeof path === "string" ? path : "", relativeRoot);
if (!scope.ok) return { block: true, reason: scope.reason };
}
return { block: false };
}
return { block: true, reason: `mosaic-core:tool-not-in-manifest:${toolName}` };
}
}
export function statusSnapshot(state: EnforcementState, activeTools: string[]): Record<string, unknown> {
if (state.state === "applied") {
return {
state: state.state,
role: state.role,
revision: state.revision,
digest: state.digest,
incarnationId: state.incarnationId,
activeTools: [...activeTools].sort(),
};
}
if (state.state === "fail-closed") {
return {
state: state.state,
cause: state.cause,
incarnationId: state.incarnationId,
activeTools: [...activeTools].sort(),
};
}
return { state: state.state, incarnationId: state.incarnationId, activeTools: [...activeTools].sort() };
}
+475
View File
@@ -0,0 +1,475 @@
// lib/gate-record.ts — T165 WP1 pure E1/E2 contracts.
//
// This module is deliberately isolated from brokers, adapters, storage, and
// provider clients. It supplies closed record validation, RFC 8785 canonical
// bytes, and digest/idempotency derivation for a later canonical writer.
import { createHash } from "node:crypto";
export const MG_CODES = ["MG01", "MG02", "MG03", "MG04", "MG05", "MG06", "MG07", "MG08"] as const;
export type MgCode = typeof MG_CODES[number];
export const FIXED_TARGET = {
host: "git.mosaicstack.dev",
repository: "mosaicstack/stack",
pullRequest: 1491,
baseBranch: "next",
headSha: "3ae1411d5f2c34174e8a0513b13b422e8c7e8c68",
headTreeSha: "6697568b78c3a910e98f848a3aa4b054d80343d8",
} as const;
/** Normative CDDL identifiers. Semantic constraints below make all maps closed. */
export const GATE_RECORD_V1_CDDL = String.raw`GateRecordV1 = GateEvidenceBundleV1 / GateVerdictV1
GateEvidenceBundleV1 = { schemaVersion: 1, recordType: "gate.merge.evidence", evidenceBundleId: uuid-v4, bundleSha256: sha256, gateProfile: "merge", requestedTransition: requested-transition, operationRequestId: uuid-v4, actorContext: actor-context, missionContext: mission-context, target: target, evidence: [8*64 evidence-item], securityReview: security-review, requiredContextSetSha256: sha256, observedAt: timestamp, expiresAt: timestamp, sensitivity: "internal" }
GateVerdictV1 = { schemaVersion: 1, recordType: "gate.merge.verdict", verdictId: uuid-v4, transitionId: uuid-v4, idempotencyKey: sha256, operationRequestId: uuid-v4, actorContext: actor-context, missionContext: mission-context, authority: authority, transition: transition, target: target, evidenceBundleId: uuid-v4, evidenceBundleSha256: sha256, decision: decision, validity: validity, sensitivity: "internal", recordSha256: sha256 }
requested-transition = { requestedFrom: "reviewed", requestedTo: "authorized-integration" }
actor-context = { principalId: opaque-id, seatId: role-slug, incarnationId: uuid-v4, sessionId: opaque-id, roleId: "gate-merge-ng", roleRevision: 1, roleDigest: sha256, assignmentId: uuid-v4, assignmentRevision: positive-int, leaseId: uuid-v4, leaseRevision: positive-int, fencingToken: positive-int }
mission-context = { missionId: uuid-v4, workUnitId: uuid-v4, workflowState: "reviewed", workflowRevision: positive-int }
target = { host: "git.mosaicstack.dev", repository: "mosaicstack/stack", pullRequest: 1491, baseBranch: "next", baseHeadSha: git-sha, headSha: "3ae1411d5f2c34174e8a0513b13b422e8c7e8c68", headTreeSha: "6697568b78c3a910e98f848a3aa4b054d80343d8", diffSha256: sha256, changedPathsSha256: sha256 }
evidence-item = { evidenceId: uuid-v4, criterionCode: criterion-code, class: evidence-class, evidenceKind: evidence-kind, producer: producer, capability: evidence-capability, source: evidence-source, targetBinding: target-binding, result: evidence-result, observedAt: timestamp, freshnessClass: freshness-class, expiresAt: timestamp / null, invalidationKeys: [1*32 invalidation-key] }
producer = { producerId: opaque-id, principalId: opaque-id, roleId: role-slug, roleRevision: positive-int, roleDigest: sha256, relationship: "independent" / "system-observer", independentOf: [*16 opaque-id] }
evidence-capability = { id: "repo.target.inspect" / "gate.evidence.observe", binding: "mosaic_gate_target_read" / "mosaic_gate_evidence_observe", backend: "mosaic-gate-broker" }
evidence-source = { sourceType: "gitea-wrapper" / "woodpecker-wrapper" / "git-object" / "brain-artifact" / "coordinator-record" / "runtime-attestation" / "journal-record", locator: locator, sourceSha256: sha256, immutable: true }
target-binding = { host: "git.mosaicstack.dev", repository: "mosaicstack/stack", pullRequest: 1491, baseHeadSha: git-sha, headSha: "3ae1411d5f2c34174e8a0513b13b422e8c7e8c68" }
evidence-result = { outcome: "pass" / "fail" / "blocked", reasonCode: reason-code / null }
security-review = { triggered: bool, triggerPolicyDigest: sha256, requiredEvidenceId: uuid-v4 / null }
authority = { policyVersion: 4, policyDigest: sha256, roleBindingId: uuid-v4, roleId: "gate-merge-ng", roleRevision: 1, roleDigest: sha256, intentId: uuid-v4, decisionId: uuid-v4, grantId: uuid-v4, invocationId: uuid-v4, assignmentId: uuid-v4, assignmentRevision: positive-int, leaseId: uuid-v4, leaseRevision: positive-int, fencingToken: positive-int, leaseExpiresAt: timestamp }
transition = { domain: "gate", objectId: "git.mosaicstack.dev/mosaicstack/stack#1491:merge", requestedFrom: "reviewed", requestedTo: "authorized-integration", expectedStateRevision: positive-int, previousState: "reviewed", previousRevision: positive-int, resultingState: "passed" / "failed" / "blocked", resultingRevision: positive-int }
decision = { verdict: "PASS" / "FAIL" / "BLOCKED", criteria: [criterion-mg01, criterion-mg02, criterion-mg03, criterion-mg04, criterion-mg05, criterion-mg06, criterion-mg07, criterion-mg08], summary: summary, failedCriteria: [*8 criterion-code], blockedCriteria: [*8 criterion-code], reasonCodes: [*8 reason-code], authorizedNextOperation: authorized-next-operation / null }
validity = { issuedAt: timestamp, expiresAt: timestamp, invalidationKeys: [1*32 invalidation-key] }
criterion-mg01 = { code: "MG01", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
criterion-mg02 = { code: "MG02", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
criterion-mg03 = { code: "MG03", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
criterion-mg04 = { code: "MG04", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
criterion-mg05 = { code: "MG05", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
criterion-mg06 = { code: "MG06", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
criterion-mg07 = { code: "MG07", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
criterion-mg08 = { code: "MG08", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
authorized-next-operation = { capability: "operation.merge", host: "git.mosaicstack.dev", repository: "mosaicstack/stack", pullRequest: 1491, headSha: "3ae1411d5f2c34174e8a0513b13b422e8c7e8c68", baseBranch: "next", strategy: "squash", operationGrantId: null }
criterion-code = "MG01" / "MG02" / "MG03" / "MG04" / "MG05" / "MG06" / "MG07" / "MG08"
criterion-outcome = "pass" / "fail" / "blocked"
evidence-class = "observation" / "verification" / "review"
evidence-kind = "pr-metadata" / "pr-diff" / "changed-paths" / "code-review" / "ci-contexts" / "merge-queue" / "security-trigger" / "security-review" / "repository-declaration" / "coordinator-authority" / "independence" / "runtime-attestation" / "journal-attestation"
freshness-class = "pr-live" / "queue-live" / "ci-head" / "review-head" / "security-policy" / "authority-submit" / "runtime-incarnation"
uuid-v4 = tstr .regexp "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
sha256 = tstr .regexp "^[0-9a-f]{64}$"
git-sha = tstr .regexp "^[0-9a-f]{40}$"
role-slug = tstr .regexp "^[a-z][a-z0-9-]{0,63}$"
opaque-id = tstr .regexp "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
timestamp = tstr .regexp "^[0-9]{4}-(0[1-9]|1[0-2])-([0-2][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9][.][0-9]{3}Z$"
locator = tstr .size (1..1024)
summary = tstr .size (1..500)
positive-int = 1..9007199254740991
reason-code = "TARGET_IDENTITY_MISMATCH" / "TARGET_HEAD_MISMATCH" / "TARGET_TREE_MISMATCH" / "TARGET_BASE_MISMATCH" / "TARGET_DIFF_MISMATCH" / "TARGET_CHANGED_PATHS_MISMATCH" / "TARGET_STATE_UNAVAILABLE" / "TARGET_DRIFT_REOBSERVE" / "REVIEW_REJECTED" / "REVIEW_TARGET_MISMATCH" / "REVIEW_INDEPENDENCE_VIOLATION" / "REVIEW_MISSING" / "REVIEW_STALE" / "REVIEW_INDEPENDENCE_UNAVAILABLE" / "SECURITY_REVIEW_REJECTED" / "SECURITY_POLICY_VIOLATION" / "SECURITY_REVIEW_MISSING" / "SECURITY_POLICY_UNAVAILABLE" / "SECURITY_EVIDENCE_STALE" / "CI_RED" / "CI_REQUIRED_CONTEXT_MISSING" / "CI_PENDING" / "CI_STATE_UNAVAILABLE" / "CI_EVIDENCE_STALE" / "QUEUE_BUSY" / "QUEUE_INDETERMINATE" / "QUEUE_EVIDENCE_STALE" / "PR_CLOSED" / "PR_DRAFT" / "PR_NOT_MERGEABLE" / "TRUNK_MISMATCH" / "MERGE_STRATEGY_MISMATCH" / "DECLARATION_INVALID" / "PR_STATE_UNAVAILABLE" / "DECLARATION_UNAVAILABLE" / "ASSIGNMENT_MISSING" / "AUTHORITY_EXPIRED" / "LEASE_EXPIRED" / "FENCE_STALE" / "WORKFLOW_REVISION_STALE" / "OPERATION_REQUEST_MISMATCH" / "AUTHORITY_BACKEND_UNAVAILABLE" / "GATE_CAPABILITY_UNAVAILABLE" / "BROKER_UNAVAILABLE" / "RUNTIME_ATTESTATION_UNAVAILABLE" / "RUNTIME_POLICY_VIOLATION" / "ROLE_RECONCILIATION_MISMATCH" / "MODEL_FLOOR_UNMET" / "JOURNAL_UNAVAILABLE" / "DENIED_PATH_CONTROL_FAILED" / "INDEPENDENCE_VIOLATION" / "INDEPENDENCE_UNAVAILABLE" / "EVIDENCE_MALFORMED" / "EVIDENCE_STALE" / "EVIDENCE_CONTRADICTORY"
invalidation-key = { kind: "pr-head" / "head-tree" / "diff" / "changed-paths" / "base-branch" / "base-head" / "pr-state" / "mergeability" / "required-context-set" / "ci-state" / "queue-state" / "review-state" / "review-independence" / "security-policy" / "security-verdict" / "mission-revision" / "work-unit-revision" / "workflow-revision" / "operation-request" / "assignment-revision" / "lease-revision" / "fencing-token" / "incarnation" / "role-binding" / "active-tool-set" / "policy-set" / "broker-availability" / "expires-at", valueSha256: sha256 }`;
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const SHA256 = /^[0-9a-f]{64}$/;
const GIT_SHA = /^[0-9a-f]{40}$/;
const ROLE = /^[a-z][a-z0-9-]{0,63}$/;
const OPAQUE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const TIMESTAMP = /^[0-9]{4}-(0[1-9]|1[0-2])-([0-2][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]\.[0-9]{3}Z$/;
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
const EVIDENCE_KINDS = new Set(["pr-metadata", "pr-diff", "changed-paths", "code-review", "ci-contexts", "merge-queue", "security-trigger", "security-review", "repository-declaration", "coordinator-authority", "independence", "runtime-attestation", "journal-attestation"]);
const SOURCE_TYPES = new Set(["gitea-wrapper", "woodpecker-wrapper", "git-object", "brain-artifact", "coordinator-record", "runtime-attestation", "journal-record"]);
const INVALIDATION_KINDS = new Set(["pr-head", "head-tree", "diff", "changed-paths", "base-branch", "base-head", "pr-state", "mergeability", "required-context-set", "ci-state", "queue-state", "review-state", "review-independence", "security-policy", "security-verdict", "mission-revision", "work-unit-revision", "workflow-revision", "operation-request", "assignment-revision", "lease-revision", "fencing-token", "incarnation", "role-binding", "active-tool-set", "policy-set", "broker-availability", "expires-at"]);
const REASONS: Record<MgCode, { fail: readonly string[]; blocked: readonly string[] }> = {
MG01: { fail: ["TARGET_IDENTITY_MISMATCH", "TARGET_HEAD_MISMATCH", "TARGET_TREE_MISMATCH", "TARGET_BASE_MISMATCH", "TARGET_DIFF_MISMATCH", "TARGET_CHANGED_PATHS_MISMATCH"], blocked: ["TARGET_STATE_UNAVAILABLE", "TARGET_DRIFT_REOBSERVE"] },
MG02: { fail: ["REVIEW_REJECTED", "REVIEW_TARGET_MISMATCH", "REVIEW_INDEPENDENCE_VIOLATION"], blocked: ["REVIEW_MISSING", "REVIEW_STALE", "REVIEW_INDEPENDENCE_UNAVAILABLE"] },
MG03: { fail: ["SECURITY_REVIEW_REJECTED", "SECURITY_POLICY_VIOLATION"], blocked: ["SECURITY_REVIEW_MISSING", "SECURITY_POLICY_UNAVAILABLE", "SECURITY_EVIDENCE_STALE"] },
MG04: { fail: ["CI_RED", "CI_REQUIRED_CONTEXT_MISSING"], blocked: ["CI_PENDING", "CI_STATE_UNAVAILABLE", "CI_EVIDENCE_STALE"] },
MG05: { fail: [], blocked: ["QUEUE_BUSY", "QUEUE_INDETERMINATE", "QUEUE_EVIDENCE_STALE"] },
MG06: { fail: ["PR_CLOSED", "PR_DRAFT", "PR_NOT_MERGEABLE", "TRUNK_MISMATCH", "MERGE_STRATEGY_MISMATCH", "DECLARATION_INVALID"], blocked: ["PR_STATE_UNAVAILABLE", "DECLARATION_UNAVAILABLE"] },
MG07: { fail: [], blocked: ["ASSIGNMENT_MISSING", "AUTHORITY_EXPIRED", "LEASE_EXPIRED", "FENCE_STALE", "WORKFLOW_REVISION_STALE", "OPERATION_REQUEST_MISMATCH", "AUTHORITY_BACKEND_UNAVAILABLE"] },
MG08: { fail: ["RUNTIME_POLICY_VIOLATION", "ROLE_RECONCILIATION_MISMATCH", "MODEL_FLOOR_UNMET", "DENIED_PATH_CONTROL_FAILED", "INDEPENDENCE_VIOLATION"], blocked: ["GATE_CAPABILITY_UNAVAILABLE", "BROKER_UNAVAILABLE", "RUNTIME_ATTESTATION_UNAVAILABLE", "JOURNAL_UNAVAILABLE", "INDEPENDENCE_UNAVAILABLE"] },
};
const UNIVERSAL_BLOCKED = ["EVIDENCE_MALFORMED", "EVIDENCE_STALE", "EVIDENCE_CONTRADICTORY"] as const;
export type GateRecordValidation = Readonly<{ ok: true; canonical: string }> | Readonly<{ ok: false; reason: string }>;
function fail(reason: string): GateRecordValidation { return Object.freeze({ ok: false, reason }); }
function pass(value: unknown): GateRecordValidation { return Object.freeze({ ok: true, canonical: canonicalizeRfc8785(value) }); }
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function closed(value: unknown, keys: readonly string[], label: string): Record<string, unknown> | undefined {
if (!isObject(value)) return undefined;
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) return undefined;
return value;
}
function text(value: unknown, min = 1, max = 1024): value is string {
return typeof value === "string" && Buffer.byteLength(value, "utf8") >= min && Buffer.byteLength(value, "utf8") <= max && value.normalize("NFC") === value && !CONTROL.test(value);
}
function sha(value: unknown): value is string { return typeof value === "string" && SHA256.test(value); }
function uuid(value: unknown): value is string { return typeof value === "string" && UUID_V4.test(value); }
function opaque(value: unknown): value is string { return typeof value === "string" && OPAQUE.test(value) && Buffer.byteLength(value, "utf8") <= 128; }
function positive(value: unknown): value is number { return typeof value === "number" && Number.isSafeInteger(value) && value >= 1; }
function timestamp(value: unknown): value is string { return typeof value === "string" && TIMESTAMP.test(value) && !Number.isNaN(Date.parse(value)) && new Date(value).toISOString() === value; }
function stringSet(value: unknown, max: number, predicate: (entry: string) => boolean, ordered = true): value is string[] {
return Array.isArray(value) && value.length <= max && value.every((entry) => typeof entry === "string" && predicate(entry)) && new Set(value).size === value.length && (!ordered || value.every((entry, index) => index === 0 || value[index - 1] < entry));
}
function exactCodes(value: unknown): value is MgCode[] { return Array.isArray(value) && value.length === MG_CODES.length && value.every((code, index) => code === MG_CODES[index]); }
function reasonAllowed(code: MgCode, outcome: string, reason: unknown): boolean {
if (outcome === "pass") return reason === null;
if (outcome !== "fail" && outcome !== "blocked") return false;
if (typeof reason !== "string") return false;
return outcome === "fail"
? REASONS[code].fail.includes(reason)
: REASONS[code].blocked.includes(reason) || (UNIVERSAL_BLOCKED as readonly string[]).includes(reason);
}
function noExtraStringControls(value: unknown): boolean {
if (typeof value === "string") return text(value, 0, Number.MAX_SAFE_INTEGER);
if (typeof value === "number") return Number.isSafeInteger(value) && !Object.is(value, -0);
if (value === null || typeof value === "boolean") return true;
if (Array.isArray(value)) return value.every(noExtraStringControls);
return isObject(value) && Object.entries(value).every(([key, nested]) => text(key, 0, Number.MAX_SAFE_INTEGER) && noExtraStringControls(nested));
}
/** RFC 8785 JSON Canonicalization Scheme bytes, after I-JSON validation. */
export function canonicalizeRfc8785(value: unknown): string {
if (!noExtraStringControls(value)) throw new Error("not I-JSON");
const render = (current: unknown): string => {
if (current === null || typeof current === "boolean") return String(current);
if (typeof current === "number") return JSON.stringify(current);
if (typeof current === "string") return JSON.stringify(current);
if (Array.isArray(current)) return `[${current.map(render).join(",")}]`;
const object = current as Record<string, unknown>;
return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${render(object[key])}`).join(",")}}`;
};
return render(value);
}
export function sha256Canonical(value: unknown): string { return createHash("sha256").update(canonicalizeRfc8785(value), "utf8").digest("hex"); }
function omitTop(record: Record<string, unknown>, field: string): Record<string, unknown> {
const copy = { ...record };
delete copy[field];
return copy;
}
function validTarget(value: unknown): value is Record<string, unknown> {
const target = closed(value, ["host", "repository", "pullRequest", "baseBranch", "baseHeadSha", "headSha", "headTreeSha", "diffSha256", "changedPathsSha256"], "target");
return !!target && target.host === FIXED_TARGET.host && target.repository === FIXED_TARGET.repository && target.pullRequest === FIXED_TARGET.pullRequest && target.baseBranch === FIXED_TARGET.baseBranch && typeof target.baseHeadSha === "string" && GIT_SHA.test(target.baseHeadSha) && target.headSha === FIXED_TARGET.headSha && target.headTreeSha === FIXED_TARGET.headTreeSha && sha(target.diffSha256) && sha(target.changedPathsSha256);
}
function validTargetBinding(value: unknown): boolean {
const binding = closed(value, ["host", "repository", "pullRequest", "baseHeadSha", "headSha"], "targetBinding");
return !!binding && binding.host === FIXED_TARGET.host && binding.repository === FIXED_TARGET.repository && binding.pullRequest === FIXED_TARGET.pullRequest && typeof binding.baseHeadSha === "string" && GIT_SHA.test(binding.baseHeadSha) && binding.headSha === FIXED_TARGET.headSha;
}
function validActor(value: unknown): value is Record<string, unknown> {
const actor = closed(value, ["principalId", "seatId", "incarnationId", "sessionId", "roleId", "roleRevision", "roleDigest", "assignmentId", "assignmentRevision", "leaseId", "leaseRevision", "fencingToken"], "actor");
return !!actor && opaque(actor.principalId) && typeof actor.seatId === "string" && ROLE.test(actor.seatId) && uuid(actor.incarnationId) && opaque(actor.sessionId) && actor.roleId === "gate-merge-ng" && actor.roleRevision === 1 && sha(actor.roleDigest) && uuid(actor.assignmentId) && positive(actor.assignmentRevision) && uuid(actor.leaseId) && positive(actor.leaseRevision) && positive(actor.fencingToken);
}
function validMission(value: unknown): value is Record<string, unknown> {
const mission = closed(value, ["missionId", "workUnitId", "workflowState", "workflowRevision"], "mission");
return !!mission && uuid(mission.missionId) && uuid(mission.workUnitId) && mission.workflowState === "reviewed" && positive(mission.workflowRevision);
}
function validInvalidationKeys(value: unknown): boolean {
if (!Array.isArray(value) || value.length < 1 || value.length > 32) return false;
let previous = "";
const seen = new Set<string>();
for (const entry of value) {
const key = closed(entry, ["kind", "valueSha256"], "invalidation");
if (!key || typeof key.kind !== "string" || !INVALIDATION_KINDS.has(key.kind) || !sha(key.valueSha256)) return false;
const order = `${key.kind}\u0000${key.valueSha256}`;
if (order <= previous || seen.has(order)) return false;
previous = order;
seen.add(order);
}
return true;
}
function validProducer(value: unknown): boolean {
const producer = closed(value, ["producerId", "principalId", "roleId", "roleRevision", "roleDigest", "relationship", "independentOf"], "producer");
if (!producer || !opaque(producer.producerId) || !opaque(producer.principalId) || typeof producer.roleId !== "string" || !ROLE.test(producer.roleId) || !positive(producer.roleRevision) || !sha(producer.roleDigest) || (producer.relationship !== "independent" && producer.relationship !== "system-observer") || !stringSet(producer.independentOf, 16, opaque)) return false;
return !producer.independentOf.includes(producer.principalId);
}
function validCapability(value: unknown): boolean {
const capability = closed(value, ["id", "binding", "backend"], "capability");
if (!capability || capability.backend !== "mosaic-gate-broker") return false;
return (capability.id === "repo.target.inspect" && capability.binding === "mosaic_gate_target_read") || (capability.id === "gate.evidence.observe" && capability.binding === "mosaic_gate_evidence_observe");
}
function validSource(value: unknown): boolean {
const source = closed(value, ["sourceType", "locator", "sourceSha256", "immutable"], "source");
return !!source && typeof source.sourceType === "string" && SOURCE_TYPES.has(source.sourceType) && text(source.locator, 1, 1024) && sha(source.sourceSha256) && source.immutable === true;
}
function expectedFreshness(kind: string): { freshness: string; class: string } | undefined {
if (["pr-metadata", "pr-diff", "changed-paths", "repository-declaration"].includes(kind)) return { freshness: "pr-live", class: "observation" };
if (kind === "code-review") return { freshness: "review-head", class: "review" };
if (kind === "ci-contexts") return { freshness: "ci-head", class: "observation" };
if (kind === "merge-queue") return { freshness: "queue-live", class: "observation" };
if (kind === "security-trigger") return { freshness: "security-policy", class: "observation" };
if (kind === "security-review") return { freshness: "security-policy", class: "review" };
if (kind === "coordinator-authority") return { freshness: "authority-submit", class: "observation" };
if (["independence", "runtime-attestation", "journal-attestation"].includes(kind)) return { freshness: "runtime-incarnation", class: "verification" };
return undefined;
}
function validEvidenceItem(value: unknown): value is Record<string, unknown> {
const item = closed(value, ["evidenceId", "criterionCode", "class", "evidenceKind", "producer", "capability", "source", "targetBinding", "result", "observedAt", "freshnessClass", "expiresAt", "invalidationKeys"], "evidence");
if (!item || !uuid(item.evidenceId) || typeof item.criterionCode !== "string" || !MG_CODES.includes(item.criterionCode as MgCode) || typeof item.class !== "string" || typeof item.evidenceKind !== "string" || !EVIDENCE_KINDS.has(item.evidenceKind) || !validProducer(item.producer) || !validCapability(item.capability) || !validSource(item.source) || !validTargetBinding(item.targetBinding) || !timestamp(item.observedAt) || !validInvalidationKeys(item.invalidationKeys)) return false;
const result = closed(item.result, ["outcome", "reasonCode"], "result");
const fresh = expectedFreshness(item.evidenceKind);
if (!result || !fresh || item.class !== fresh.class || item.freshnessClass !== fresh.freshness || !reasonAllowed(item.criterionCode as MgCode, String(result.outcome), result.reasonCode)) return false;
if (item.expiresAt !== null && !timestamp(item.expiresAt)) return false;
if (item.expiresAt === null && item.freshnessClass !== "review-head" && item.freshnessClass !== "runtime-incarnation") return false;
return true;
}
type CriterionDerivation = Readonly<{
outcome: "pass" | "fail" | "blocked";
reasonCode: string | null;
}>;
/**
* E1 is the only source of E2 criterion outcomes. The first applicable reason
* follows the CDDL's left-to-right priority after all evidence for that
* criterion has been considered. A caller cannot choose a weaker reason or a
* PASS result over a negative observation.
*/
function deriveCriterion(code: MgCode, items: readonly Record<string, unknown>[]): CriterionDerivation | undefined {
const outcomes = items.map((item) => (item.result as Record<string, unknown>).outcome);
const outcome = outcomes.includes("fail")
? "fail"
: outcomes.includes("blocked")
? "blocked"
: outcomes.every((entry) => entry === "pass")
? "pass"
: undefined;
if (!outcome) return undefined;
if (outcome === "pass") return Object.freeze({ outcome, reasonCode: null });
const priority = outcome === "fail"
? REASONS[code].fail
: [...REASONS[code].blocked, ...UNIVERSAL_BLOCKED];
for (const reasonCode of priority) {
if (items.some((item) => (item.result as Record<string, unknown>).reasonCode === reasonCode)) {
return Object.freeze({ outcome, reasonCode });
}
}
return undefined;
}
export function validateGateEvidenceBundle(value: unknown): GateRecordValidation {
const bundle = closed(value, ["schemaVersion", "recordType", "evidenceBundleId", "bundleSha256", "gateProfile", "requestedTransition", "operationRequestId", "actorContext", "missionContext", "target", "evidence", "securityReview", "requiredContextSetSha256", "observedAt", "expiresAt", "sensitivity"], "bundle");
if (!bundle) return fail("E1:closed-map");
if (bundle.schemaVersion !== 1 || bundle.recordType !== "gate.merge.evidence" || !uuid(bundle.evidenceBundleId) || !sha(bundle.bundleSha256) || bundle.gateProfile !== "merge" || !uuid(bundle.operationRequestId) || !validActor(bundle.actorContext) || !validMission(bundle.missionContext) || !validTarget(bundle.target) || !sha(bundle.requiredContextSetSha256) || !timestamp(bundle.observedAt) || !timestamp(bundle.expiresAt) || bundle.sensitivity !== "internal") return fail("E1:scalar");
if (!closed(bundle.requestedTransition, ["requestedFrom", "requestedTo"], "requestedTransition") || (bundle.requestedTransition as Record<string, unknown>).requestedFrom !== "reviewed" || (bundle.requestedTransition as Record<string, unknown>).requestedTo !== "authorized-integration") return fail("E1:transition");
if (!Array.isArray(bundle.evidence) || bundle.evidence.length < 8 || bundle.evidence.length > 64) return fail("E1:evidence-count");
const ids = new Set<string>();
const byCriterion = new Map<MgCode, Record<string, unknown>[]>();
let prior = "";
for (const item of bundle.evidence) {
if (!validEvidenceItem(item)) return fail("E1:evidence-item");
const evidence = item as Record<string, unknown>;
const id = evidence.evidenceId as string;
const criterion = evidence.criterionCode as MgCode;
const order = `${criterion}\u0000${evidence.evidenceKind as string}\u0000${id}`;
if (ids.has(id) || order <= prior) return fail("E1:evidence-order");
const targetBinding = evidence.targetBinding as Record<string, unknown>;
if (targetBinding.baseHeadSha !== (bundle.target as Record<string, unknown>).baseHeadSha) return fail("E1:target-binding");
ids.add(id); prior = order;
byCriterion.set(criterion, [...(byCriterion.get(criterion) ?? []), evidence]);
}
if (!MG_CODES.every((code) => (byCriterion.get(code)?.length ?? 0) >= 1)) return fail("E1:criterion-coverage");
const security = closed(bundle.securityReview, ["triggered", "triggerPolicyDigest", "requiredEvidenceId"], "security");
if (!security || typeof security.triggered !== "boolean" || !sha(security.triggerPolicyDigest) || (security.triggered ? !uuid(security.requiredEvidenceId) || !ids.has(security.requiredEvidenceId as string) : security.requiredEvidenceId !== null)) return fail("E1:security");
if (security.triggered) {
const match = bundle.evidence.find((item) => (item as Record<string, unknown>).evidenceId === security.requiredEvidenceId) as Record<string, unknown> | undefined;
if (!match || match.criterionCode !== "MG03" || match.evidenceKind !== "security-review") return fail("E1:security-reference");
}
const observed = bundle.evidence.map((item) => (item as Record<string, unknown>).observedAt as string).sort().at(-1);
if (bundle.observedAt !== observed) return fail("E1:observed-at");
const expiries = bundle.evidence.map((item) => (item as Record<string, unknown>).expiresAt).filter((entry): entry is string => typeof entry === "string").sort();
if (expiries.length > 0 && bundle.expiresAt !== expiries[0]) return fail("E1:expires-at");
if (bundle.bundleSha256 !== sha256Canonical(omitTop(bundle, "bundleSha256"))) return fail("E1:bundle-digest");
return pass(bundle);
}
function validAuthority(value: unknown, actor: Record<string, unknown>): boolean {
const authority = closed(value, ["policyVersion", "policyDigest", "roleBindingId", "roleId", "roleRevision", "roleDigest", "intentId", "decisionId", "grantId", "invocationId", "assignmentId", "assignmentRevision", "leaseId", "leaseRevision", "fencingToken", "leaseExpiresAt"], "authority");
return !!authority && authority.policyVersion === 4 && sha(authority.policyDigest) && uuid(authority.roleBindingId) && authority.roleId === "gate-merge-ng" && authority.roleRevision === 1 && authority.roleDigest === actor.roleDigest && uuid(authority.intentId) && uuid(authority.decisionId) && uuid(authority.grantId) && uuid(authority.invocationId) && authority.assignmentId === actor.assignmentId && authority.assignmentRevision === actor.assignmentRevision && authority.leaseId === actor.leaseId && authority.leaseRevision === actor.leaseRevision && authority.fencingToken === actor.fencingToken && timestamp(authority.leaseExpiresAt);
}
function validTransition(value: unknown): value is Record<string, unknown> {
const transition = closed(value, ["domain", "objectId", "requestedFrom", "requestedTo", "expectedStateRevision", "previousState", "previousRevision", "resultingState", "resultingRevision"], "transition");
return !!transition && transition.domain === "gate" && transition.objectId === "git.mosaicstack.dev/mosaicstack/stack#1491:merge" && transition.requestedFrom === "reviewed" && transition.requestedTo === "authorized-integration" && positive(transition.expectedStateRevision) && transition.previousState === "reviewed" && transition.previousRevision === transition.expectedStateRevision && ["passed", "failed", "blocked"].includes(String(transition.resultingState)) && transition.resultingRevision === (transition.expectedStateRevision as number) + 1;
}
function validAuthorizedOperation(value: unknown): boolean {
return !!closed(value, ["capability", "host", "repository", "pullRequest", "headSha", "baseBranch", "strategy", "operationGrantId"], "operation") && exactFixed(value, { capability: "operation.merge", host: FIXED_TARGET.host, repository: FIXED_TARGET.repository, pullRequest: FIXED_TARGET.pullRequest, headSha: FIXED_TARGET.headSha, baseBranch: FIXED_TARGET.baseBranch, strategy: "squash", operationGrantId: null });
}
function exactFixed(value: unknown, expected: Record<string, unknown>): boolean {
const obj = closed(value, Object.keys(expected), "fixed");
return !!obj && Object.keys(expected).every((key) => obj[key] === expected[key]);
}
function derivedIdempotency(verdict: Record<string, unknown>): string {
const actor = verdict.actorContext as Record<string, unknown>;
const target = verdict.target as Record<string, unknown>;
const transition = verdict.transition as Record<string, unknown>;
const source = {
assignmentId: actor.assignmentId,
assignmentRevision: actor.assignmentRevision,
baseHeadSha: target.baseHeadSha,
expectedStateRevision: transition.expectedStateRevision,
headSha: target.headSha,
operationRequestId: verdict.operationRequestId,
requestedFrom: "reviewed",
requestedTo: "authorized-integration",
transitionId: verdict.transitionId,
};
return createHash("sha256").update("mosaic-gate-verdict-v1\n", "utf8").update(canonicalizeRfc8785(source), "utf8").digest("hex");
}
function validDecision(
value: unknown,
transition: Record<string, unknown>,
evidenceById: ReadonlyMap<string, Record<string, unknown>>,
): { ok: true } | { ok: false } {
const decision = closed(value, ["verdict", "criteria", "summary", "failedCriteria", "blockedCriteria", "reasonCodes", "authorizedNextOperation"], "decision");
if (!decision || !["PASS", "FAIL", "BLOCKED"].includes(String(decision.verdict)) || !text(decision.summary, 1, 500) || !Array.isArray(decision.criteria) || decision.criteria.length !== MG_CODES.length || !Array.isArray(decision.failedCriteria) || !Array.isArray(decision.blockedCriteria) || !stringSet(decision.reasonCodes, 8, (code) => typeof code === "string")) return { ok: false };
const expectedFailed: MgCode[] = [];
const expectedBlocked: MgCode[] = [];
const expectedReasonCodes: string[] = [];
const referencedIds = new Set<string>();
for (let index = 0; index < MG_CODES.length; index++) {
const criterion = closed(decision.criteria[index], ["code", "outcome", "reasonCode", "evidenceIds"], "criterion");
const code = MG_CODES[index];
if (!criterion || criterion.code !== code || !["pass", "fail", "blocked"].includes(String(criterion.outcome)) || !Array.isArray(criterion.evidenceIds) || criterion.evidenceIds.length < 1 || criterion.evidenceIds.length > 16 || !stringSet(criterion.evidenceIds, 16, uuid)) return { ok: false };
const evidence: Record<string, unknown>[] = [];
for (const id of criterion.evidenceIds) {
if (referencedIds.has(id)) return { ok: false };
const item = evidenceById.get(id);
if (!item || item.criterionCode !== code) return { ok: false };
referencedIds.add(id);
evidence.push(item);
}
const derived = deriveCriterion(code, evidence);
if (!derived || criterion.outcome !== derived.outcome || criterion.reasonCode !== derived.reasonCode) return { ok: false };
if (derived.outcome === "fail") expectedFailed.push(code);
if (derived.outcome === "blocked") expectedBlocked.push(code);
if (derived.reasonCode !== null) expectedReasonCodes.push(derived.reasonCode);
}
if (referencedIds.size !== evidenceById.size) return { ok: false };
if (!sameOrderedCodes(decision.failedCriteria, expectedFailed) || !sameOrderedCodes(decision.blockedCriteria, expectedBlocked)) return { ok: false };
const expectedReasons = [...new Set(expectedReasonCodes)].sort();
if (JSON.stringify(decision.reasonCodes) !== JSON.stringify(expectedReasons)) return { ok: false };
const derivedVerdict = expectedFailed.length > 0 ? "FAIL" : expectedBlocked.length > 0 ? "BLOCKED" : "PASS";
const expectedState = derivedVerdict === "PASS" ? "passed" : derivedVerdict === "FAIL" ? "failed" : "blocked";
if (decision.verdict !== derivedVerdict || transition.resultingState !== expectedState) return { ok: false };
if ((derivedVerdict === "PASS" && !validAuthorizedOperation(decision.authorizedNextOperation)) || (derivedVerdict !== "PASS" && decision.authorizedNextOperation !== null)) return { ok: false };
return { ok: true };
}
function sameOrderedCodes(value: unknown, expected: MgCode[]): boolean { return Array.isArray(value) && value.length === expected.length && value.every((code, index) => code === expected[index]); }
function validValidity(value: unknown): value is Record<string, unknown> {
const validity = closed(value, ["issuedAt", "expiresAt", "invalidationKeys"], "validity");
return !!validity && timestamp(validity.issuedAt) && timestamp(validity.expiresAt) && validInvalidationKeys(validity.invalidationKeys) && validity.issuedAt <= validity.expiresAt;
}
function sameCanonical(left: unknown, right: unknown): boolean {
try {
return canonicalizeRfc8785(left) === canonicalizeRfc8785(right);
} catch {
return false;
}
}
/**
* E2 is valid only at the broker's supplied submit time. Requiring the caller
* to supply that time prevents an offline E2 shape check from masquerading as
* an operation-authorizing validation.
*/
function validityBoundAtSubmit(
validity: Record<string, unknown>,
bundle: Record<string, unknown>,
authority: Record<string, unknown>,
submittedAt: unknown,
): boolean {
if (!timestamp(submittedAt)) return false;
const issuedAt = validity.issuedAt as string;
const expiresAt = validity.expiresAt as string;
const evidenceObservedAt = bundle.observedAt as string;
const evidenceExpiresAt = bundle.expiresAt as string;
const leaseExpiresAt = authority.leaseExpiresAt as string;
return issuedAt === submittedAt
&& evidenceObservedAt <= submittedAt
&& expiresAt <= evidenceExpiresAt
&& expiresAt <= leaseExpiresAt
&& submittedAt <= expiresAt
&& submittedAt <= evidenceExpiresAt
&& submittedAt <= leaseExpiresAt;
}
export function validateGateVerdict(value: unknown, bundle?: unknown, submittedAt?: unknown): GateRecordValidation {
const verdict = closed(value, ["schemaVersion", "recordType", "verdictId", "transitionId", "idempotencyKey", "operationRequestId", "actorContext", "missionContext", "authority", "transition", "target", "evidenceBundleId", "evidenceBundleSha256", "decision", "validity", "sensitivity", "recordSha256"], "verdict");
if (!verdict) return fail("E2:closed-map");
if (verdict.schemaVersion !== 1 || verdict.recordType !== "gate.merge.verdict" || !uuid(verdict.verdictId) || !uuid(verdict.transitionId) || !sha(verdict.idempotencyKey) || !uuid(verdict.operationRequestId) || !validActor(verdict.actorContext) || !validMission(verdict.missionContext) || !validTarget(verdict.target) || !uuid(verdict.evidenceBundleId) || !sha(verdict.evidenceBundleSha256) || !validValidity(verdict.validity) || verdict.sensitivity !== "internal" || !sha(verdict.recordSha256)) return fail("E2:scalar");
const actor = verdict.actorContext as Record<string, unknown>;
if (!validAuthority(verdict.authority, actor) || !validTransition(verdict.transition)) return fail("E2:authority-transition");
const authority = verdict.authority as Record<string, unknown>;
if (verdict.transitionId !== authority.invocationId || verdict.idempotencyKey !== derivedIdempotency(verdict)) return fail("E2:idempotency");
if (bundle === undefined) return fail("E2:bundle-required");
const bundleCheck = validateGateEvidenceBundle(bundle);
if (!bundleCheck.ok) return fail("E2:bundle-invalid");
const evidence = bundle as Record<string, unknown>;
if (
verdict.evidenceBundleId !== evidence.evidenceBundleId
|| verdict.evidenceBundleSha256 !== evidence.bundleSha256
|| verdict.operationRequestId !== evidence.operationRequestId
|| !sameCanonical(verdict.actorContext, evidence.actorContext)
|| !sameCanonical(verdict.missionContext, evidence.missionContext)
|| !sameCanonical(verdict.target, evidence.target)
) return fail("E2:bundle-binding");
if (!validityBoundAtSubmit(verdict.validity, evidence, authority, submittedAt)) return fail("E2:validity");
const evidenceById = new Map<string, Record<string, unknown>>((evidence.evidence as unknown[]).map((item) => [(item as Record<string, unknown>).evidenceId as string, item as Record<string, unknown>]));
const decision = validDecision(verdict.decision, verdict.transition as Record<string, unknown>, evidenceById);
if (!decision.ok) return fail("E2:decision");
try {
if (verdict.recordSha256 !== sha256Canonical(omitTop(verdict, "recordSha256"))) return fail("E2:record-digest");
} catch {
return fail("E2:record-digest");
}
return pass(verdict);
}
export function validateGateRecord(value: unknown): GateRecordValidation {
if (!isObject(value)) return fail("record:not-object");
if (value.recordType === "gate.merge.evidence") return validateGateEvidenceBundle(value);
if (value.recordType === "gate.merge.verdict") return validateGateVerdict(value);
return fail("record:unknown-type");
}
/** Parse untrusted JSON with duplicate-key, I-JSON, and closed-schema checks. */
export function parseGateRecordJson(raw: string): GateRecordValidation {
try {
assertNoDuplicateJsonKeys(raw);
return validateGateRecord(JSON.parse(raw));
} catch {
return fail("record:invalid-json");
}
}
function assertNoDuplicateJsonKeys(raw: string): void {
let index = 0;
const whitespace = (): void => { while (/\s/.test(raw[index] ?? "")) index++; };
const string = (): string => {
const start = index;
if (raw[index++] !== '"') throw new Error("string");
while (index < raw.length) {
const char = raw[index++];
if (char === '"') return JSON.parse(raw.slice(start, index));
if (char === "\\") { const escaped = raw[index++]; if (escaped === "u") index += 4; else if (escaped === undefined) throw new Error("escape"); }
else if (char < " ") throw new Error("control");
}
throw new Error("unterminated");
};
const value = (): void => {
whitespace(); const char = raw[index];
if (char === "{") { index++; whitespace(); const keys = new Set<string>(); if (raw[index] === "}") { index++; return; } while (true) { whitespace(); const key = string(); if (keys.has(key)) throw new Error("duplicate"); keys.add(key); whitespace(); if (raw[index++] !== ":") throw new Error("colon"); value(); whitespace(); if (raw[index] === "}") { index++; return; } if (raw[index++] !== ",") throw new Error("comma"); } }
if (char === "[") { index++; whitespace(); if (raw[index] === "]") { index++; return; } while (true) { value(); whitespace(); if (raw[index] === "]") { index++; return; } if (raw[index++] !== ",") throw new Error("comma"); } }
if (char === '"') { string(); return; }
const token = /^(?:true|false|null|-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)/.exec(raw.slice(index));
if (!token) throw new Error("value");
index += token[0].length;
};
value(); whitespace(); if (index !== raw.length) throw new Error("trailing");
}
+228
View File
@@ -0,0 +1,228 @@
// lib/goal-policy.ts — process-local, one-writer Goal policy bridge (T165 WP1).
//
// Goal registers the read-only consumer before Mosaic Core starts a session.
// A trusted launcher or role adapter registers exactly one resolver, then
// Mosaic Core publishes one validated attestation for the incarnation. This
// module has no filesystem, network, provider, or model-visible surface.
export const GOAL_REPORT_FORMAT = "ms-executive-update/v1";
export const GOAL_REPORT_CONTRACT_PATH = "skills-local/ms-executive-update/SKILL.md";
export const GOAL_REPORT_CONTRACT_SECTION = "Machine contract (for `goal_report` payloads and any parser)";
export const GOAL_REPORT_CONTRACT_BLOB = "df30c6fbb54b4a65a298c9e51c07f742610d171c";
export const GOAL_REPORT_CONTRACT_SHA256 = "bbea48a46b1f8da7bc759f86856fb52830b7dde456b826317163c6dc6ccab319";
export const GOAL_POLICY_ENFORCEMENT = "pre-state-change-fail-closed";
export const GOAL_POLICY_IDENTIFIER_RESOLUTION = "consumer-fail-closed";
export type GoalPolicyRole = "gate-merge-ng" | "plan-ng" | "review-ng";
export type GoalPolicyRoleRevision = 3 | 4;
export type GoalPolicyAttestationV1 = Readonly<{
schemaVersion: 1;
role: GoalPolicyRole;
roleRevision: GoalPolicyRoleRevision;
manifestSha256: string;
format: typeof GOAL_REPORT_FORMAT;
contractPath: typeof GOAL_REPORT_CONTRACT_PATH;
contractSection: typeof GOAL_REPORT_CONTRACT_SECTION;
contractBlob: typeof GOAL_REPORT_CONTRACT_BLOB;
contractSha256: typeof GOAL_REPORT_CONTRACT_SHA256;
enforcement: typeof GOAL_POLICY_ENFORCEMENT;
identifierResolution: typeof GOAL_POLICY_IDENTIFIER_RESOLUTION;
launchGeneration: number;
incarnationId: string;
}>;
export type GoalItemResolutionV1 =
| Readonly<{
outcome: "resolved";
objectId: string;
objectSha256: string;
changedSincePreviousAcceptedReport: boolean;
completionEvidenceId: string | null;
}>
| Readonly<{ outcome: "zero" | "multiple" | "unavailable" | "stale" }>;
export type GoalTrackedItem = Readonly<{
token: string;
section: "Just Completed" | "Next Step" | "Blocked";
}>;
export type GoalTrackedItemResolverV1 = (parsedItem: GoalTrackedItem) => Promise<GoalItemResolutionV1>;
export type GoalPolicyReason =
| "consumer-unavailable"
| "resolver-unavailable"
| "resolver-already-registered"
| "already-published"
| "malformed-attestation"
| "attestation-mismatch"
| "incarnation-mismatch";
export interface GoalPolicyPublication {
attestation: GoalPolicyAttestationV1;
resolver: GoalTrackedItemResolverV1;
/** Trusted Core journal bridge. Receives a stable code only, never payload text. */
onDenial?: (reason: string) => void;
}
interface GoalPolicySlot {
consumerRegistered: boolean;
resolver?: GoalTrackedItemResolverV1;
publication?: GoalPolicyPublication;
}
const REGISTRY_KEY = Symbol.for("mosaic.goal-policy.v1.registry");
type GlobalWithGoalPolicy = typeof globalThis & { [REGISTRY_KEY]?: Map<string, GoalPolicySlot> };
function registry(): Map<string, GoalPolicySlot> {
const global = globalThis as GlobalWithGoalPolicy;
if (!global[REGISTRY_KEY]) {
Object.defineProperty(global, REGISTRY_KEY, {
value: new Map<string, GoalPolicySlot>(),
configurable: false,
enumerable: false,
writable: false,
});
}
return global[REGISTRY_KEY]!;
}
function utf8Length(value: string): number {
return Buffer.byteLength(value, "utf8");
}
export function isOpaqueGoalPolicyId(value: unknown): value is string {
return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(value) && utf8Length(value) >= 1 && utf8Length(value) <= 128;
}
export function isLowerSha256(value: unknown): value is string {
return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
}
function roleRevisionMatches(role: GoalPolicyRole, revision: GoalPolicyRoleRevision): boolean {
return (role === "gate-merge-ng" && revision === 3) || ((role === "plan-ng" || role === "review-ng") && revision === 4);
}
function hasExactKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
}
export function validateGoalPolicyAttestation(value: unknown): value is GoalPolicyAttestationV1 {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
const attestation = value as Record<string, unknown>;
if (!hasExactKeys(attestation, [
"schemaVersion", "role", "roleRevision", "manifestSha256", "format", "contractPath",
"contractSection", "contractBlob", "contractSha256", "enforcement", "identifierResolution",
"launchGeneration", "incarnationId",
])) return false;
if (attestation.schemaVersion !== 1) return false;
if (attestation.role !== "gate-merge-ng" && attestation.role !== "plan-ng" && attestation.role !== "review-ng") return false;
if (attestation.roleRevision !== 3 && attestation.roleRevision !== 4) return false;
if (!roleRevisionMatches(attestation.role, attestation.roleRevision)) return false;
if (!isLowerSha256(attestation.manifestSha256)) return false;
if (attestation.format !== GOAL_REPORT_FORMAT) return false;
if (attestation.contractPath !== GOAL_REPORT_CONTRACT_PATH) return false;
if (attestation.contractSection !== GOAL_REPORT_CONTRACT_SECTION) return false;
if (attestation.contractBlob !== GOAL_REPORT_CONTRACT_BLOB) return false;
if (attestation.contractSha256 !== GOAL_REPORT_CONTRACT_SHA256) return false;
if (attestation.enforcement !== GOAL_POLICY_ENFORCEMENT) return false;
if (attestation.identifierResolution !== GOAL_POLICY_IDENTIFIER_RESOLUTION) return false;
if (typeof attestation.launchGeneration !== "number" || !Number.isSafeInteger(attestation.launchGeneration) || attestation.launchGeneration < 1) return false;
return isOpaqueGoalPolicyId(attestation.incarnationId);
}
function freezeAttestation(value: GoalPolicyAttestationV1): GoalPolicyAttestationV1 {
return Object.freeze({ ...value });
}
/** Goal calls this during extension initialization, before Mosaic Core publishes. */
export function registerGoalPolicyConsumer(incarnationId: string): { ok: true } | { ok: false; reason: "incarnation-mismatch" } {
if (!isOpaqueGoalPolicyId(incarnationId)) return { ok: false, reason: "incarnation-mismatch" };
const slots = registry();
const slot = slots.get(incarnationId) ?? { consumerRegistered: false };
slot.consumerRegistered = true;
slots.set(incarnationId, slot);
return { ok: true };
}
/**
* A trusted launcher or role adapter supplies a resolver before Core publishes.
* WP1 deliberately supplies no fallback resolver: missing resolver authority
* keeps a version-4 role fail-closed until the later broker/adapter work exists.
*/
export function registerGoalTrackedItemResolver(
incarnationId: string,
resolver: GoalTrackedItemResolverV1,
): { ok: true } | { ok: false; reason: GoalPolicyReason } {
if (!isOpaqueGoalPolicyId(incarnationId)) return { ok: false, reason: "incarnation-mismatch" };
if (typeof resolver !== "function") return { ok: false, reason: "resolver-unavailable" };
const slot = registry().get(incarnationId);
if (!slot?.consumerRegistered) return { ok: false, reason: "consumer-unavailable" };
if (slot.resolver) return { ok: false, reason: "resolver-already-registered" };
slot.resolver = resolver;
return { ok: true };
}
/** Mosaic Core is the only policy publisher and may publish once per incarnation. */
export function publishGoalPolicy(
value: GoalPolicyAttestationV1,
onDenial?: (reason: string) => void,
): { ok: true; attestation: GoalPolicyAttestationV1 } | { ok: false; reason: GoalPolicyReason } {
if (!validateGoalPolicyAttestation(value)) return { ok: false, reason: "malformed-attestation" };
const slot = registry().get(value.incarnationId);
if (!slot?.consumerRegistered) return { ok: false, reason: "consumer-unavailable" };
if (!slot.resolver) return { ok: false, reason: "resolver-unavailable" };
if (slot.publication) return { ok: false, reason: "already-published" };
const attestation = freezeAttestation(value);
slot.publication = Object.freeze({ attestation, resolver: slot.resolver, onDenial });
return { ok: true, attestation };
}
/** Read-only consumer view. No caller can replace a published pair. */
export function goalPolicyPublication(incarnationId: string): GoalPolicyPublication | undefined {
return registry().get(incarnationId)?.publication;
}
/** Goal records a stable, payload-free denial through the Core-owned journal bridge. */
export function recordGoalPolicyDenial(incarnationId: string, reason: string): void {
try {
registry().get(incarnationId)?.publication?.onDenial?.(reason);
} catch {
// A journal outage cannot turn a fail-closed report denial into an allow.
}
}
/** Core pre-dispatch check: attestation must still be the exact published pair. */
export function verifyGoalPolicyPublication(
expected: GoalPolicyAttestationV1,
): { ok: true } | { ok: false; reason: GoalPolicyReason } {
if (!validateGoalPolicyAttestation(expected)) return { ok: false, reason: "malformed-attestation" };
const published = goalPolicyPublication(expected.incarnationId);
if (!published) return { ok: false, reason: "resolver-unavailable" };
const actual = published.attestation;
const same = JSON.stringify(actual) === JSON.stringify(expected);
return same ? { ok: true } : { ok: false, reason: "attestation-mismatch" };
}
/** Validate the closed resolver result before Goal uses it. */
export function validateGoalItemResolution(value: unknown): value is GoalItemResolutionV1 {
if (typeof value !== "object" || value === null || Array.isArray(value) || !Object.isFrozen(value)) return false;
const result = value as Record<string, unknown>;
if (result.outcome === "zero" || result.outcome === "multiple" || result.outcome === "unavailable" || result.outcome === "stale") {
return hasExactKeys(result, ["outcome"]);
}
if (result.outcome !== "resolved" || !hasExactKeys(result, [
"outcome", "objectId", "objectSha256", "changedSincePreviousAcceptedReport", "completionEvidenceId",
])) return false;
return isOpaqueGoalPolicyId(result.objectId)
&& isLowerSha256(result.objectSha256)
&& typeof result.changedSincePreviousAcceptedReport === "boolean"
&& (result.completionEvidenceId === null || isOpaqueGoalPolicyId(result.completionEvidenceId));
}
/** Test-only registry reset. Production code has no reset or replacement path. */
export function resetGoalPolicyRegistryForTest(): void {
registry().clear();
}
+61
View File
@@ -0,0 +1,61 @@
// lib/incarnation.ts — process-launch incarnation identity (DESIGN HAZARD).
//
// Mercer-measured hazard, carried by the NG-1 tasking: Pi documents
// PI_SESSION_ID as SESSION identity, not incarnation, and Mercer's
// environment carries no incarnation variable. Per L2-D01/D05 we therefore
// do NOT relabel PI_SESSION_ID as an incarnation — the R6 journal keys on
// the identity minted here.
//
// Resolution order (pure function of process state + injectable rng):
// 1. Launcher claim: MOSAIC_LAUNCH_INCARNATION, VALIDATED (NG-7 F2): the
// claim becomes a FILENAME COMPONENT in the journal and goal-state
// paths, so anything outside ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ is
// IGNORED (no path separators, no leading dot, no traversal shape) and
// resolution falls through — an invalid claim must never reach a path.
// 2. Process-global: globalThis.__mosaicCoreIncarnation — set on first
// mint. globalThis is per-process, so the identity survives extension
// /reload (module re-import) and session switches WITHIN the process.
// Deliberately NOT exported via process.env: child shells must not
// inherit a forgeable-looking claim.
// 3. Mint: rng() — DEFAULTS to crypto.randomUUID (NG-7 F1: the first cut
// required an injectable rng with no default, and both production
// call sites invoked incarnationIdentity() with no argument — a live
// startup throw the test suites never caught because every arm
// injected an rng or an explicit incarnationId).
//
// This function never reads PI_SESSION_ID. There is no code path here that
// could conflate session identity with launch identity.
import { randomUUID } from "node:crypto";
export const LAUNCH_CLAIM_ENV = "MOSAIC_LAUNCH_INCARNATION";
const GLOBAL_SLOT = "__mosaicCoreIncarnation";
/** A claim is usable only if it is a safe single path component. */
export const CLAIM_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
type Rng = () => string;
export function validClaim(claim: string | undefined): claim is string {
return typeof claim === "string" && CLAIM_RE.test(claim);
}
export function incarnationIdentity(rng: Rng = () => randomUUID()): string {
const claim = process.env[LAUNCH_CLAIM_ENV];
if (validClaim(claim)) {
return claim;
}
const g = globalThis as Record<string, unknown>;
const existing = g[GLOBAL_SLOT];
if (typeof existing === "string" && existing.length > 0) {
return existing;
}
const minted = rng();
g[GLOBAL_SLOT] = minted;
return minted;
}
/** Test/adapter helper: clear the process-global slot (never touches claims). */
export function resetIncarnationGlobal(): void {
delete (globalThis as Record<string, unknown>)[GLOBAL_SLOT];
}
+62
View File
@@ -0,0 +1,62 @@
// lib/journal.ts — per-incarnation append-only JSONL journal (PRD R6, NG-3).
//
// One file per incarnation identity (the NG-1 process-launch identity —
// NEVER PI_SESSION_ID) under the XDG state home:
// ${XDG_STATE_HOME:-~/.local/state}/mosaic-core/<incarnationId>.jsonl
//
// Append-only is structural: the only write primitive is appendFileSync of a
// newline-terminated JSON line. No read-modify-write, no truncation, no
// rewrite — a journal that can be edited is a journal that cannot prove
// anything. Event payloads are pre-sanitized by their producers (the denial
// event carries tool+reason only; proposals pass the proposal sanitizer).
import { appendFileSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
export type JournalKind = "reconciliation" | "denial" | "improvement-proposal";
export interface JournalEvent {
ts: string;
incarnationId: string;
kind: JournalKind;
[field: string]: unknown;
}
export interface JournalIO {
mkdirSync(path: string, opts: { recursive: boolean }): void;
appendFileSync(path: string, data: string): void;
}
function defaultIO(): JournalIO {
return { mkdirSync, appendFileSync };
}
export function journalDir(stateHome: string | undefined, home: string = homedir()): string {
return join(stateHome ?? join(home, ".local", "state"), "mosaic-core");
}
export interface Journal {
path: string;
append(event: Omit<JournalEvent, "ts" | "incarnationId">): void;
}
export function createJournal(opts: {
incarnationId: string;
stateHome?: string;
io?: JournalIO;
now?: () => string;
}): Journal {
const io = opts.io ?? defaultIO();
const now = opts.now ?? (() => new Date().toISOString());
const dir = journalDir(opts.stateHome);
const path = join(dir, `${opts.incarnationId}.jsonl`);
io.mkdirSync(dir, { recursive: true });
return {
path,
append(event) {
const line = `${JSON.stringify({ ts: now(), incarnationId: opts.incarnationId, ...event })}\n`;
io.appendFileSync(path, line);
},
};
}
+273
View File
@@ -0,0 +1,273 @@
// lib/loader.ts — trusted-path role-manifest and v4 Goal-policy loading.
//
// Version-3 loading retains its established manifest path and digest behavior.
// Version-4 adds one trusted, non-symlinked contract read at session start and
// emits an immutable policy attestation. There is no cwd-relative path,
// environment override, second manifest reader, or policy fallback.
import { createHash } from "node:crypto";
import { validateManifest, type RoleManifest, type RoleManifestV4 } from "./policy.ts";
import {
GOAL_POLICY_ENFORCEMENT,
GOAL_POLICY_IDENTIFIER_RESOLUTION,
GOAL_REPORT_CONTRACT_BLOB,
GOAL_REPORT_CONTRACT_PATH,
GOAL_REPORT_CONTRACT_SECTION,
GOAL_REPORT_CONTRACT_SHA256,
GOAL_REPORT_FORMAT,
isOpaqueGoalPolicyId,
type GoalPolicyAttestationV1,
} from "./goal-policy.ts";
export const MANIFEST_BASENAME = "mosaic-core.manifest.json";
export type LoadFailure =
| { stage: "no-agent-name" }
| { stage: "no-seat-profile" }
| { stage: "no-role-in-profile" }
| { stage: "manifest-unreadable"; detail: string }
| { stage: "manifest-symlink" }
| { stage: "invalid-json"; detail: string }
| { stage: "schema"; reason: string; detail?: string }
| { stage: "role-mismatch"; expected: string; got: string }
| { stage: "goal-policy-unreadable" }
| { stage: "goal-policy-root-symlink" }
| { stage: "goal-policy-parent-symlink" }
| { stage: "goal-policy-symlink" }
| { stage: "goal-policy-path-escape" }
| { stage: "goal-policy-non-file" }
| { stage: "goal-policy-invalid-utf8" }
| { stage: "goal-policy-digest" }
| { stage: "goal-policy-section" }
| { stage: "goal-policy-launch-generation" }
| { stage: "goal-policy-incarnation" }
| { stage: "goal-policy-publication" };
export interface TrustedGoalPolicy {
attestation: GoalPolicyAttestationV1;
}
export type LoadOutcome =
| { ok: true; manifest: RoleManifest; digest: string; path: string; goalPolicy?: TrustedGoalPolicy }
| { ok: false; failure: LoadFailure };
export interface LoaderIO {
readFileSync(path: string): string | Uint8Array;
lstatSync(path: string): { isSymbolicLink(): boolean; isFile(): boolean };
/** Required for v4 contract containment. Missing capability fails v4 closed. */
realpathSync?(path: string): string;
}
export interface LoadContext {
incarnationId?: string;
launchGeneration?: number;
}
export function manifestPath(brainHome: string, role: string): string {
return `${brainHome}/fleet/roles/${role}/${MANIFEST_BASENAME}`;
}
export function seatRole(brainHome: string, agentName: string | undefined, io: LoaderIO): string | null {
if (!agentName || !/^[a-z][a-z0-9-]*$/.test(agentName)) return null;
let profileRaw: string;
try {
profileRaw = decodeUtf8(io.readFileSync(`${brainHome}/fleet/agents/${agentName}/profile.json`));
} catch {
return null;
}
try {
const profile = JSON.parse(profileRaw) as Record<string, unknown>;
const role = profile.role;
if (typeof role === "string" && /^[a-z][a-z0-9-]*$/.test(role)) return role;
} catch {
// fall through
}
return null;
}
/** Trusted manifest loader. v4 context is mandatory only for a v4 manifest. */
export function loadTrustedManifest(brainHome: string, role: string, io: LoaderIO, context: LoadContext = {}): LoadOutcome {
const path = manifestPath(brainHome, role);
let st: ReturnType<LoaderIO["lstatSync"]>;
try {
st = io.lstatSync(path);
} catch (error) {
return { ok: false, failure: { stage: "manifest-unreadable", detail: String(error) } };
}
if (st.isSymbolicLink()) return { ok: false, failure: { stage: "manifest-symlink" } };
let rawBytes: Uint8Array;
let raw: string;
try {
rawBytes = toBytes(io.readFileSync(path));
raw = decodeUtf8(rawBytes);
} catch (error) {
return { ok: false, failure: { stage: "manifest-unreadable", detail: String(error) } };
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
return { ok: false, failure: { stage: "invalid-json", detail: String(error) } };
}
const verdict = validateManifest(parsed);
if (!verdict.ok) {
return { ok: false, failure: { stage: "schema", reason: verdict.reason ?? "invalid", detail: verdict.detail } };
}
if (verdict.manifest!.role !== role) {
return { ok: false, failure: { stage: "role-mismatch", expected: role, got: verdict.manifest!.role } };
}
const digest = sha256(rawBytes);
if (verdict.manifest!.schemaVersion !== 4) {
return { ok: true, manifest: verdict.manifest!, digest, path };
}
const goalPolicy = loadGoalPolicy(brainHome, verdict.manifest!, digest, io, context);
if (!goalPolicy.ok) return goalPolicy;
return { ok: true, manifest: verdict.manifest!, digest, path, goalPolicy: goalPolicy.goalPolicy };
}
function loadGoalPolicy(
brainHome: string,
manifest: RoleManifestV4,
manifestSha256: string,
io: LoaderIO,
context: LoadContext,
): { ok: true; goalPolicy: TrustedGoalPolicy } | { ok: false; failure: LoadFailure } {
const policy = manifest.goalReportPolicy;
// validateManifest already pins every field. Repeat explicit constants here
// so a future policy-validator regression cannot silently change the loader.
if (
policy.format !== GOAL_REPORT_FORMAT
|| policy.contractPath !== GOAL_REPORT_CONTRACT_PATH
|| policy.contractSection !== GOAL_REPORT_CONTRACT_SECTION
|| policy.contractBlob !== GOAL_REPORT_CONTRACT_BLOB
|| policy.contractSha256 !== GOAL_REPORT_CONTRACT_SHA256
|| policy.enforcement !== GOAL_POLICY_ENFORCEMENT
|| policy.identifierResolution !== GOAL_POLICY_IDENTIFIER_RESOLUTION
) {
return { ok: false, failure: { stage: "goal-policy-digest" } };
}
const trustedPath = trustedContractPath(brainHome, policy.contractPath, io);
if (!trustedPath.ok) return { ok: false, failure: { stage: trustedPath.stage } };
const contractPath = trustedPath.path;
let stat: ReturnType<LoaderIO["lstatSync"]>;
try {
stat = io.lstatSync(contractPath);
} catch {
return { ok: false, failure: { stage: "goal-policy-unreadable" } };
}
if (stat.isSymbolicLink()) return { ok: false, failure: { stage: "goal-policy-symlink" } };
if (!stat.isFile()) return { ok: false, failure: { stage: "goal-policy-non-file" } };
let bytes: Uint8Array;
let source: string;
try {
bytes = toBytes(io.readFileSync(contractPath));
source = decodeUtf8(bytes);
} catch {
return { ok: false, failure: { stage: "goal-policy-invalid-utf8" } };
}
if (sha256(bytes) !== policy.contractSha256 || gitBlobSha1(bytes) !== policy.contractBlob) {
return { ok: false, failure: { stage: "goal-policy-digest" } };
}
const heading = `## ${policy.contractSection}`;
const first = source.indexOf(heading);
if (first < 0 || source.indexOf(heading, first + heading.length) !== -1) {
return { ok: false, failure: { stage: "goal-policy-section" } };
}
const generation = context.launchGeneration;
if (typeof generation !== "number" || !Number.isSafeInteger(generation) || generation < 1) {
return { ok: false, failure: { stage: "goal-policy-launch-generation" } };
}
if (!isOpaqueGoalPolicyId(context.incarnationId)) {
return { ok: false, failure: { stage: "goal-policy-incarnation" } };
}
const attestation: GoalPolicyAttestationV1 = Object.freeze({
schemaVersion: 1,
role: manifest.role,
roleRevision: manifest.revision,
manifestSha256,
format: policy.format,
contractPath: policy.contractPath,
contractSection: policy.contractSection,
contractBlob: policy.contractBlob,
contractSha256: policy.contractSha256,
enforcement: policy.enforcement,
identifierResolution: policy.identifierResolution,
launchGeneration: generation,
incarnationId: context.incarnationId,
});
return { ok: true, goalPolicy: { attestation } };
}
type TrustedContractPath =
| Readonly<{ ok: true; path: string }>
| Readonly<{
ok: false;
stage: "goal-policy-unreadable" | "goal-policy-root-symlink" | "goal-policy-parent-symlink" | "goal-policy-symlink" | "goal-policy-path-escape";
}>;
/**
* Resolve each path component without allowing a contract parent to redirect
* a trusted-root read. The leaf check alone is insufficient because
* `skills-local` itself can be a symlink to matching bytes outside the brain.
*/
function trustedContractPath(brainHome: string, relative: string, io: LoaderIO): TrustedContractPath {
if (relative !== GOAL_REPORT_CONTRACT_PATH || relative.startsWith("/") || relative.split("/").some((part) => part === "" || part === "." || part === "..")) {
return Object.freeze({ ok: false, stage: "goal-policy-unreadable" });
}
const root = brainHome.replace(/\/+$/, "");
if (!root.startsWith("/") || root === "" || !io.realpathSync) {
return Object.freeze({ ok: false, stage: "goal-policy-unreadable" });
}
try {
const rootStat = io.lstatSync(root);
if (rootStat.isSymbolicLink()) return Object.freeze({ ok: false, stage: "goal-policy-root-symlink" });
const canonicalRoot = io.realpathSync(root).replace(/\/+$/, "");
if (!canonicalRoot.startsWith("/") || canonicalRoot === "") {
return Object.freeze({ ok: false, stage: "goal-policy-unreadable" });
}
const parts = relative.split("/");
let path = root;
for (let index = 0; index < parts.length; index++) {
path = `${path}/${parts[index]}`;
const stat = io.lstatSync(path);
if (stat.isSymbolicLink()) {
return Object.freeze({ ok: false, stage: index === parts.length - 1 ? "goal-policy-symlink" : "goal-policy-parent-symlink" });
}
}
const canonicalPath = io.realpathSync(path);
if (canonicalPath !== canonicalRoot && !canonicalPath.startsWith(`${canonicalRoot}/`)) {
return Object.freeze({ ok: false, stage: "goal-policy-path-escape" });
}
return Object.freeze({ ok: true, path });
} catch {
return Object.freeze({ ok: false, stage: "goal-policy-unreadable" });
}
}
function toBytes(value: string | Uint8Array): Uint8Array {
return typeof value === "string" ? Buffer.from(value, "utf8") : Buffer.from(value);
}
function decodeUtf8(value: string | Uint8Array): string {
if (typeof value === "string") return value;
return new TextDecoder("utf-8", { fatal: true }).decode(value);
}
export function sha256(value: string | Uint8Array): string {
return createHash("sha256").update(value).digest("hex");
}
export function gitBlobSha1(bytes: Uint8Array): string {
const header = Buffer.from(`blob ${bytes.byteLength}\0`, "utf8");
return createHash("sha1").update(header).update(bytes).digest("hex");
}
+666
View File
@@ -0,0 +1,666 @@
// 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" }) } } : {}),
},
};
}
+87
View File
@@ -0,0 +1,87 @@
// lib/proposal.ts — structured improvement-proposal sanitization (PRD R7, AC7).
//
// An improvement proposal is RECORDED, never applied: the only consumer is
// the append-only journal. The sanitizer is the boundary that makes "record
// structured proposals" safe to expose to the model — strict shape, bounded
// text, normalized control characters, unknown fields rejected (same
// strictness doctrine as the manifest validator: tolerating extra fields
// means being unable to catch drift).
export interface SanitizedProposal {
target: string; // what the proposal concerns: role slug, tool name, or policy field
summary: string; // <= 500 chars, single line
motivation: string; // <= 2000 chars, newlines normalized to spaces
evidence?: string[]; // 0..8 items, each <= 500 chars
}
export type ProposalRejection =
| "not-an-object"
| "unknown-field"
| "missing-field"
| "invalid-target"
| "oversized"
| "invalid-evidence";
export interface ProposalResult {
ok: boolean;
proposal?: SanitizedProposal;
reason?: ProposalRejection;
detail?: string;
}
const FIELDS = new Set(["target", "summary", "motivation", "evidence"]);
const SLUG_RE = /^[a-z][a-z0-9_.:-]*$/i;
const LIMITS = { summary: 500, motivation: 2000, evidenceItems: 8, evidenceItem: 500 };
function normalize(s: unknown): string | null {
if (typeof s !== "string") return null;
// collapse all line-breaking and control characters to single spaces so a
// proposal can never smuggle structure past the JSONL line boundary
return s.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
}
export function sanitizeProposal(input: unknown): ProposalResult {
if (typeof input !== "object" || input === null || Array.isArray(input)) {
return { ok: false, reason: "not-an-object" };
}
const obj = input as Record<string, unknown>;
for (const key of Object.keys(obj)) {
if (!FIELDS.has(key)) return { ok: false, reason: "unknown-field", detail: `unexpected field "${key}"` };
}
for (const field of ["target", "summary", "motivation"]) {
if (!(field in obj)) return { ok: false, reason: "missing-field", detail: `missing field "${field}"` };
}
const target = normalize(obj.target);
if (target === null || target === "" || !SLUG_RE.test(target) || target.length > 100) {
return { ok: false, reason: "invalid-target" };
}
const summary = normalize(obj.summary);
if (summary === null || summary === "") return { ok: false, reason: "invalid-target", detail: "summary empty" };
if (summary.length > LIMITS.summary) return { ok: false, reason: "oversized", detail: "summary" };
const motivation = normalize(obj.motivation);
if (motivation === null || motivation === "") {
return { ok: false, reason: "invalid-target", detail: "motivation empty" };
}
if (motivation.length > LIMITS.motivation) return { ok: false, reason: "oversized", detail: "motivation" };
let evidence: string[] | undefined;
if ("evidence" in obj) {
const raw = obj.evidence;
if (!Array.isArray(raw) || raw.length > LIMITS.evidenceItems) {
return { ok: false, reason: "invalid-evidence", detail: "evidence must be an array of at most 8 items" };
}
const items: string[] = [];
for (const item of raw) {
const n = normalize(item);
if (n === null || n === "") return { ok: false, reason: "invalid-evidence" };
if (n.length > LIMITS.evidenceItem) return { ok: false, reason: "oversized", detail: "evidence item" };
items.push(n);
}
evidence = items;
}
return { ok: true, proposal: { target, summary, motivation, evidence } };
}
+28
View File
@@ -0,0 +1,28 @@
// lib/reconcile.ts — pure exact active-tool reconciliation (PRD R3 core, AC2).
//
// The reconciliation output is the COMPLETE target state: applying it makes
// the active tool set EXACTLY the manifest's set — no more (an injected
// third-party tool is removed), no less (a manifest tool missing from the
// active set is listed for addition). NG-2 binds this to setActiveTools();
// this module never touches Pi state.
export interface ReconcilePlan {
/** the exact target active set (sorted manifest tools) */
exact: string[];
/** active tools absent from the manifest — must be removed */
toRemove: string[];
/** manifest tools absent from the active set — must be added */
toAdd: string[];
/** true when the active set already equals the manifest set */
unchanged: boolean;
}
export function reconcileActiveTools(manifestTools: string[], activeTools: string[]): ReconcilePlan {
const target = [...new Set(manifestTools)].sort();
const active = [...new Set(activeTools)].sort();
const inManifest = new Set(target);
const inActive = new Set(active);
const toRemove = active.filter((t) => !inManifest.has(t));
const toAdd = target.filter((t) => !inActive.has(t));
return { exact: target, toRemove, toAdd, unchanged: toRemove.length === 0 && toAdd.length === 0 };
}