315 lines
14 KiB
TypeScript
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]
|
|
}
|