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

373 lines
14 KiB
TypeScript

// 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() };
}