import type { FastifyInstance } from 'fastify'; /** * Read-only runtime (issue #524). * * A runtime can satisfy every property of our isolated-runtime definition — its * own ANIMA_HOME, an app id no live agent holds, no bot token, no subscriber, no * provider loaded — and still mutate machine-scoped state that every live agent * depends on. Those properties all answer *"what does this runtime connect to?"*. * None answers *"what can this runtime do?"*. * * Connection isolation is not capability isolation. Isolation is of the home, not * of the machine: provider binaries on PATH, locks under ~/.cache/anima/**, the * machine user's Keychain, OS service-manager labels — all of them cross the * ANIMA_HOME boundary. * * `read-only` is a property, not a use. A property can be checked; a use cannot. * That is why this is not called "preview mode". * * ENFORCEMENT LIVES HERE, AT THE ROUTE LAYER, AND NOWHERE ELSE. * The dashboard may reflect the mode honestly (a disabled control that says why), * but the UI is not the enforcement point. A hidden button is theater: the route * is still mounted and `curl` still reaches it. * * A rule enforced by prose fails silently. A rule enforced by a 403 fails red. */ export declare const READ_ONLY_ENV = "ANIMA_READ_ONLY"; /** The refusal is never bare. A refusal that cannot say why it refused gets read as a bug and routed around. */ export declare const READ_ONLY_REFUSAL = "read-only runtime: machine-scoped mutation disabled"; export interface GovernedRoute { /** Stable id, used in the refusal body and in tests. */ readonly id: string; readonly method: 'DELETE' | 'GET' | 'POST' | 'PUT'; /** Matched against the pathname only (query string stripped). */ readonly pattern: RegExp; /** * WHY this route is machine-scoped. This is evidence read out of the code, not * a guess. Guessing a list manufactures exactly the "looks contained" illusion * this mode exists to remove. * * It ships to the caller inside the 403, so it is part of the refusal's contract: * state the STABLE PROPERTY, never a current constant. A constant (a path, a * default, a root) is configuration; it goes stale, and worse, it invites the * reader to conclude the route is safe once the constant changes. The property is * what makes the route machine-scoped, and the property is what survives. */ readonly evidence: string; } /** * The governed set. * * Selection criterion: **the route mutates state that lives outside ANIMA_HOME and * is shared by every process owned by this machine user.** * * Note carefully what the criterion is NOT. It is not "the route takes the * machine-wide advisory lease", and it is not "the route is a POST". The lease * proves machine scope where it is taken; its *absence* proves nothing. The two * provider-usage GETs below take no lease and are not POSTs, and they rewrite the * machine user's OAuth credentials. * * Cross-checked against Milo's source-first capability inventory * (anima-team @ bb0e7ad). Applying the property above independently reproduced his * set. `POST /api/agents/:agentId/home` was CONSIDERED AND EXCLUDED: * ensureExistingAgentHome() only stats the path and throws unless it is already a * directory. It validates; it does not create. Over-blocking is also a failure — * a guard that refuses more than it can justify teaches people to route around it. * * Still the *proven* set, not the *complete* one, and the difference is the whole * point of the mode. */ export declare const GOVERNED_ROUTES: readonly GovernedRoute[]; /** * The explicit machine-write opt-in (issue #524, cut 2). * * Today a runtime that forgets `ANIMA_READ_ONLY` gets full machine permissions. That * is a rule enforced by remembering, which is the same object as a rule enforced by * prose. The end state inverts it: refuse by default, and let a runtime that genuinely * must write the machine (live, which really does have to refresh credentials) say so. * * THE ORDER IS LOAD-BEARING AND CANNOT BE COMPRESSED: * 1. ship this flag, MEANINGFUL from day one <- this cut * 2. live sets it, and is verified still working <- a live action: totoday only * 3. only then does unset flip from "permit" to "refuse" * Doing 3 before 2 makes live start 403-ing on upgrade, and live is the one machine * that must write credentials. We would have traded live for a correct default. */ export declare const MACHINE_WRITE_ENV = "ANIMA_ALLOW_MACHINE_WRITES"; /** * Rides on every governed-route response THAT REACHES THIS GUARD — including the * refusal and including the handler's own 4xx. * * ⚠️ THE QUALIFIER IS THE CONTRACT, NOT A FOOTNOTE. Dashboard auth is a `preHandler` * registered BEFORE this one, so an UNAUTHENTICATED governed request is answered `401` * and this hook never runs: no header. An older build that predates this cut: no header. * An ungoverned path: no header, by design. * * So an ABSENT header is not a pass and not a fail — it means the probe never reached * the guard. The only affirmative reading is the value itself. Anyone verifying a * machine must require that they positively READ `explicit`; "we didn't see * implicit-default" is the same nothing a dead guard prints. (@milo reproduced the 401 * case on this PR; I had written "EVERY governed response", which was false.) * * Given that, the design still holds: the safe probe (`POST /api/filesystem/mkdir` at a * nonexistent parent, which 404s at `realpath()` before writing anything) carries the * header out on its 404, so an AUTHENTICATED caller can read a running machine's mode * with ZERO machine action. That is what step 2 needs: verify live is opted in, without * making live write. */ export declare const MACHINE_WRITE_HEADER = "x-anima-machine-writes"; export type MachineWriteMode = /** ANIMA_READ_ONLY is on. Governed routes are refused 403. */ 'refused' /** Opted in on purpose. This is what live will carry. */ | 'explicit' /** * Nobody said anything, so we permit — for now. This is the state we are trying to * delete, so it is never silent: it warns, and it is visible on the wire. If a * process we thought was safe is sitting in this state, we want to find out from an * instrument, not from an incident. */ | 'implicit-default'; export declare function isReadOnlyRuntime(env?: NodeJS.ProcessEnv): boolean; export declare function allowsMachineWrites(env?: NodeJS.ProcessEnv): boolean; /** * Read-only WINS over the machine-write opt-in. * * Both set is a contradiction, and a guard that resolves a contradiction toward * permission is a guard you can talk out of refusing. Refusal is the safe reading, and * it is also the honest one: someone who set both did not mean "write the machine". */ export declare function machineWriteMode(readOnly: boolean, allowMachineWrites: boolean): MachineWriteMode; /** * THE ONLY PLACE THIS PRECEDENCE IS WRITTEN. * * The first draft of this cut stated the rule twice — here, and again inline in the * guard — and tested only this one. Mutation m6 walked straight through: flip the * guard's copy and a process with BOTH flags set permits the write, with every test * still green. Two copies of a safety rule is one copy that is untested by construction, * and it is always the copy that runs. */ export declare function resolveMachineWriteMode(env?: NodeJS.ProcessEnv): MachineWriteMode; /** Pure, so the table can be tested without standing up a server. */ export declare function governedRouteFor(method: string, url: string): GovernedRoute | undefined; export interface ReadOnlyGuardOptions { /** Defaults to the ANIMA_READ_ONLY environment variable. Injectable for tests. */ readonly readOnly?: boolean; /** Defaults to the ANIMA_ALLOW_MACHINE_WRITES environment variable. Injectable for tests. */ readonly allowMachineWrites?: boolean; /** * Where the implicit-default warning goes. Defaults to stderr. * * Injectable because a warning nobody can observe is a warning nobody can test, and * an untested warning is the kind that turns out to have been silent all along. */ readonly warn?: (message: string) => void; } /** * Register AFTER `registerDashboardAuthGuard`, and before any route. * * This is a `preHandler`, not an `onRequest`, and the distinction is load-bearing. * Dashboard auth checks at `preHandler`; `onRequest` runs strictly earlier. A * read-only guard on `onRequest` would answer an UNAUTHENTICATED governed request * with this detailed 403 — leaking the route inventory and its evidence to a caller * who was never entitled to a reply, and quietly replacing the `401 * authentication_required` contract. * * Same-type hooks run in registration order, so auth answers first (401), and only * an authenticated caller reaches the read-only refusal (403). Both still run ahead * of the handler, so the governed handler never executes: the refusal remains a * closed door, not a late abort inside one. */ export declare function registerReadOnlyGuard(fastify: FastifyInstance, options?: ReadOnlyGuardOptions): void; //# sourceMappingURL=read-only.d.ts.map