274 lines
10 KiB
TypeScript
274 lines
10 KiB
TypeScript
// 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");
|
|
}
|