@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user