// 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: // mosaic-core:fail-closed:: // mosaic-core:not-applied: // mosaic-core:read-missing-path // mosaic-core:read-scope-unbound // mosaic-core:read-credential-denied: // mosaic-core:read-outside-scope: // mosaic-core:read-symlink-escape: 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//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; /** C3 read-scope roots. Symbolic "@brain" or absolute. Empty = C3 unbound. */ readScope: readonly string[]; } | { state: "fail-closed"; incarnationId: string; cause: string; allowed: ReadonlySet; 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 { 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 => 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 { 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() }; }