373 lines
14 KiB
TypeScript
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() };
|
|
}
|