Files
stack/extensions/mosaic-core/lib/adapter.ts
T

315 lines
14 KiB
TypeScript

// 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]
}