import type { TurnQuiescence } from '../adapters/backend/mojo-process-tree.js'; export interface StrongContainmentHandle { kind: 'cgroup'; sessionId: string; /** Worker generation that acquired it; kept for operator-facing logs only. */ generation: number; /** Absolute cgroup v2 directory owning the turn subtree. */ cgroupPath: string; /** Env nonce injected into the tree, so a degraded scan can still corroborate. */ nonce: string; /** * Boot id at mint time. A reboot provably kills the whole tree — including the * same-UID sibling/parent migrant that cgroup emptiness cannot see — so a * changed boot id is the ONE thing that lets a cgroup handle release (it is * the same unforgeable fact a weak handle uses). Optional so a handle written * by an older daemon (no bootId) still parses; such a legacy handle simply * never gets the reboot proof and waits for an operator revoke. */ bootId?: string; } export interface WeakContainmentHandle { kind: 'tree-identity'; sessionId: string; generation: number; rootPid: number; /** Boot identity, so a pid from a previous boot is never re-signalled. */ bootId: string; /** `/proc//stat` field 22, which makes pid reuse detectable. */ startTime: number; nonce: string; } /** * A tree we can neither contain nor describe. * * Reached when the host offers NO usable mechanism: no cgroup v2 delegation, and * no readable boot id / starttime (a non-Linux host, or a locked-down /proc). The * turn still spawned a credentialed child, so the honest record is not "nothing to * track" — it is "something exists here that this host can never prove gone". * * Why this exists rather than returning null * ----------------------------------------- * `acquireContainmentHandle` used to return null in this case, which meant the * caller recorded NOTHING and `hasUnprovenContainment()` answered false — so the * device-isolation blocker was not retained on exactly the platform that cannot * prove anything. That inverted the intended fail-closed direction, and it did so * silently, because "no handle" is indistinguishable from "no turn ever ran". * * An unprovable handle is deliberately a DEAD END: `proveContainmentQuiescent` * can never return `proven: true` for it, so the type-level guard on * `releaseContainmentHandle` makes it impossible to release. The session's blocker * therefore stays for the lifetime of the record, which is the correct answer when * a credentialed subtree existed and the host cannot ever demonstrate its death. * * The platform is recorded so an operator can tell "macOS, no cgroups" apart from * "Linux with /proc unreadable" without re-deriving it. */ export interface UnprovableContainmentHandle { kind: 'unprovable'; sessionId: string; generation: number; nonce: string; /** `process.platform` at acquisition time. */ platform: string; /** Why nothing stronger could be minted, for operator-facing logs. */ reason: string; } export type ContainmentHandle = StrongContainmentHandle | WeakContainmentHandle | UnprovableContainmentHandle; /** * Result of asking "is everything this handle owns gone?". * * `proven: false` deliberately carries no "probably fine" variant: every * non-proof (alive, unreadable, unsupported, timed out) is the same verdict to a * caller, because all of them must keep the blocker. */ export type QuiescenceVerdict = { proven: true; handle: ContainmentHandle; reason?: string; evidence?: QuiescenceEvidence; } | { proven: false; handle: ContainmentHandle; reason: string; residualPids?: number[]; }; /** * WHICH fact settled a `proven: true` verdict. The distinction is the entire * safety argument of this module, so it is carried in the data instead of being * re-derived by every caller: * * - `boot-id-changed`: the recorded tree cannot have survived the reboot that * changed the kernel boot id, and a same-user child cannot fake a boot id -> * THE boundary proof (the only one). Applies to a weak handle and, once its * bootId is stamped, to a cgroup handle too. * - `cgroup-empty` / `cgroup-zombie-only`: kernel membership shows the leaf * subtree empty. DIAGNOSTIC ONLY, NOT a boundary proof: mojo runs at the * daemon's UID and cgroup v2 lets a same-UID process migrate ITSELF out of * the leaf (to the parent slice or a sibling), invisible to the leaf-down * read. Good for stopping signals + killing (cgroup.kill), never for dropping * device isolation. * - `scan-clean`: a /proc subtree scan came back empty. DIAGNOSTIC ONLY: a * descendant that calls setsid(), scrubs its own environ and reparents to * init evades enumeration entirely, so this can never authorise dropping * device isolation. * * An absent field is read as "the strongest thing this handle KIND could prove", * for compatibility with hand-built verdicts; it can never be read as stronger * than the handle kind allows. */ export type QuiescenceEvidence = 'cgroup-empty' | 'cgroup-zombie-only' | 'boot-id-changed' | 'scan-clean'; /** What stays isolated after a close that could not prove its boundary. */ export interface ContainmentResidual { /** True keeps the device-isolation blocker even though the session may close. */ deviceIsolation: boolean; /** Pids the evidence named, when it named any. */ pids?: number[]; /** Operator-facing explanation of what is still unproven. */ reason?: string; } /** * The single authority on "may this handle be forgotten, and may the blocker go?". * * `boundaryProof` is the only field that answer may consult, and unlike the * previous revision that statement now has a real production consumer: * `releaseContainmentHandle` branches on `releaseAuthorised` below, so a clean * weak scan cannot reach the removal path at all. */ export interface ContainmentReleaseDecision { /** Unforgeable boundary evidence. The gate. */ boundaryProof: boolean; /** `verdict.proven && boundaryProof` - the only state that removes a handle. */ releaseAuthorised: boolean; /** Which fact was available, or `not-proven` when quiescence itself failed. */ evidence: QuiescenceEvidence | 'not-proven'; /** Non-null whenever the handle stays behind; null only on a real release. */ residual: ContainmentResidual | null; /** May the caller stop re-signalling? True once quiescence itself is proven. */ signalsStopped: boolean; } /** Thrown instead of degrading to an empty (fail-open) handle store. */ export declare class MojoContainmentUnavailableError extends Error { readonly cause?: unknown | undefined; constructor(message: string, cause?: unknown | undefined); } /** * Boot identity of the running kernel. * * Returns null when it cannot be read (non-Linux, or a locked-down /proc). * Callers must treat null as "cannot mint a trustworthy weak handle", NOT as a * blank value to store: a handle whose bootId is empty would compare equal * across reboots and across hosts, resurrecting exactly the pid-reuse confusion * the field exists to prevent. */ export declare function readBootId(opts?: { procRoot?: string; }): string | null; /** * `/proc//stat` field 22 (starttime, in clock ticks since boot). * * null means the pid is not currently live (or is unreadable), which is why the * caller may never read null as "the tree is gone": the ROOT exiting says * nothing about a descendant that called setsid(). * * Field indexing has the same comm hazard as the scanner: field 2 is * parenthesised and may contain spaces or ')', so the split starts after the LAST * ')'. From there, index 0 is state (field 3), hence starttime (field 22) sits at * index 19. */ export declare function readProcStartTime(pid: number, opts?: { procRoot?: string; }): number | null; /** * Is a usable cgroup v2 hierarchy mounted? * * `cgroup.controllers` exists only on the v2 unified hierarchy, so its presence * is the cheap discriminator against a v1-only host (where per-session * containment via this module is not available). */ export declare function cgroupV2Available(opts?: { cgroupRoot?: string; }): boolean; /** * Process state from `/proc//stat` field 3, for zombie classification. * * 'zombie' — state Z: the process has exited and is only waiting to be * reaped. It executes no instructions and cannot use a * credential, but it REMAINS a member of its cgroup until the * parent reaps it (and `rmdir` keeps failing while it does). * 'running' — any other state. Treated as executing. * 'gone' — ENOENT: the pid vanished between listing and reading, i.e. * genuinely not a member any more (same race rule the scanner * applies). * 'unreadable' — anything else. Deliberately NOT merged into 'zombie': a state * we cannot read must count as executing, or an EACCES becomes * a free pass. * * DUPLICATED RULE — read before changing any of the three cases * ------------------------------------------------------------ * The same zombie rule has to hold in TWO places that deliberately do not share * code: here, for cgroup members read out of `cgroup.procs`, and in the /proc * subtree scanner (mojo-process-tree), for members found by enumeration. They stay * separate because they start from different inputs, but they must agree, and * nothing enforces that agreement automatically. * * Why the agreement matters more than the rule itself: if one side discounts a * zombie and the other does not, the SAME tree gets two verdicts. That is not a * cosmetic inconsistency — ProcTree hit it for real while wiring 7-A. A SIGKILLed * child sat in state Z awaiting reap; the in-memory ladder discounted it and * reported clean, while the pid list handed to `proveContainmentQuiescent` still * carried it, so this module judged the tree alive. The close was then refused * forever and the handle could never be discharged: a permanent wedge produced * purely by two definitions of "running". * * So: change one side and you MUST change the other. The rule is exactly * - state 'Z' -> discounted (a zombie executes nothing, holds no credential) * - ENOENT -> gone, skipped (raced away; not a member) * - anything else -> EXECUTING, including any state we cannot read * The last case is the one that must never be relaxed on either side: turning an * unreadable state into "harmless" is a fail-open, and it is reachable by the very * process being policed. */ export type ProcLiveness = 'zombie' | 'running' | 'gone' | 'unreadable'; export declare function readProcLiveness(pid: number, opts?: { procRoot?: string; }): ProcLiveness; export interface AcquireContainmentInput { sessionId: string; generation: number; /** Root pid of the turn (the direct child this backend spawned). */ rootPid: number; /** The env nonce already injected into that tree. */ nonce: string; } /** * A cgroup boundary created BEFORE the turn child is spawned. * * The strong handle used to be minted post-spawn by migrating the root pid into * a fresh cgroup. That left a spawn→enrolment window: cgroup v2 migration does * not retroactively move a process's already-forked descendants * (admin-guide/cgroup-v2: "Migration of a process doesn't affect already * existing descendant processes"), so a descendant forked (and setsid'd) inside * the window was outside the cgroup while `cgroup.procs` — the whole strong * proof — read empty after the root exited. The proof went false-green and the * device-isolation blocker was cleared with a credentialed escapee still alive. * * The boundary is therefore prepared first, and the child enrols ITSELF into it * before exec'ing the target binary (see MOJO_CGROUP_ENROLL_SHIM in * mojo-backend). Between fork and enrolment the process runs only our trusted * shim, which forks nothing, so every descendant of the target binary is born * inside the cgroup — that is the precondition the strong proof relies on. */ export interface PreparedContainmentBoundary { sessionId: string; generation: number; cgroupPath: string; nonce: string; } /** * Create the cgroup directory for a turn about to be spawned. * * Returns null when the host cannot support a strong boundary (no cgroup v2, or * the slice is not writable) — the caller falls back to the weak post-spawn * handle, which never claims boundary proof. */ export declare function prepareContainmentBoundary(input: { sessionId: string; generation: number; nonce: string; }, opts?: { cgroupRoot?: string; procRoot?: string; }): PreparedContainmentBoundary | null; /** Mint the strong handle for a boundary the child has enrolled itself into. */ export declare function strongHandleFromPreparedBoundary(prepared: PreparedContainmentBoundary, opts?: { procRoot?: string; }): ContainmentHandle; /** * Kill every process currently enrolled in a prepared boundary (cgroup.kill), * then remove the directory if that emptied it. * * For the caller who spawned a child through the enrolment shim and then FAILED * to record the handle durably: the subtree must not be left running behind a * blocker nobody recorded. Returns true only when the boundary is provably * empty (or already gone) afterwards — false means the caller must keep its * own fence up. */ export declare function killPreparedBoundary(prepared: PreparedContainmentBoundary): Promise; /** * Mint the strongest POST-SPAWN handle this host can describe a turn with. * * Deliberately NEVER a cgroup handle: migrating an already-running root into a * cgroup does not capture descendants it forked before the write, so a strong * handle minted here would claim a boundary that provably has a hole (the P0 * this module was rewritten for). Strong handles exist only via * prepareContainmentBoundary + the pre-exec enrolment shim. This function is * the fallback for hosts (or spawn paths) where that was not possible, and the * handles it mints never carry boundary proof. */ export declare function acquireContainmentHandle(input: AcquireContainmentInput, opts?: { cgroupRoot?: string; procRoot?: string; platform?: string; }): ContainmentHandle; /** * Outcome of a degraded, /proc-based enumeration, supplied by the caller. * * Deliberately a callback rather than a direct import of `scanMojoTree`: the * scanner is owned by mojo-process-tree.ts and this module must stay a leaf that * a unit test can drive with a synthetic world. `scanned: false` is the scanner's * fail-closed signal and MUST NOT be collapsed into an empty pid list by the * caller. */ export interface TreeScanEvidence { scanned: boolean; pids: readonly number[]; reason?: string; } export interface ProveContainmentOpts { procRoot?: string; /** Required to prove a WEAK handle; ignored for a strong one. */ scan?: (handle: WeakContainmentHandle) => TreeScanEvidence; } /** * Can we prove that nothing this handle owns is still executing? * * STRONG handle: `cgroup.procs` is authoritative. An absent directory also counts * as proof, because the kernel refuses `rmdir` on a non-empty cgroup and this * module only removes one after a proven verdict — so "gone" can only mean * "was empty when it went". Any OTHER read error is a non-proof. * * WEAK handle: * - a bootId mismatch is genuine, cheap proof: the recorded tree cannot have * survived the reboot that changed the id; * - otherwise the only available evidence is a /proc scan, which the caller * must supply. No scan, or a failed scan, or any surviving pid → not proven. * - the root pid being gone is explicitly NOT accepted on its own: a descendant * that called setsid() outlives its parent, which is the whole reason the * scanner unions three signals. * * A clean weak verdict is the best this host can do, not an unforgeable * boundary — see the trust-domain note in mojo-process-tree. Callers that need * certainty need a strong handle. */ export declare function proveContainmentQuiescent(handle: ContainmentHandle, opts?: ProveContainmentOpts): QuiescenceVerdict; /** * Is the recorded root pid still the ORIGINAL process? * * Used before signalling: a weak handle names a pid, and pids are reused. Sending * SIGKILL to a recycled pid would kill an unrelated process, so a caller must * confirm identity first. False therefore means "do not signal this pid", NOT * "the tree is gone". */ export declare function weakHandleRootStillOriginal(handle: WeakContainmentHandle, opts?: { procRoot?: string; }): boolean; /** Stable identity of a handle, so union/removal cannot double-count or mis-hit. */ export declare function containmentHandleKey(handle: ContainmentHandle): string; /** * Record a handle as OWNED AND UNPROVEN. * * Call this at spawn time, before the child can do anything: a crash between * spawn and record is exactly the window that used to lose the tree entirely. * Monotonic union by handle identity, so re-recording is idempotent and a later * call can never retract an earlier one. * * THROWS on any read/write failure — the caller must not proceed believing the * tree was recorded. */ export declare function recordContainmentHandle(handle: ContainmentHandle, dataDir?: string): void; /** * Handles this session still owns. Empty ONLY when nothing is outstanding. * * THROWS when the store cannot be read, so isolation callers fail closed instead * of reading an error as "clean". */ export declare function containmentHandles(sessionId: string, dataDir?: string): ContainmentHandle[]; /** * Session ids with an outstanding handle, INCLUDING sessions whose row is gone. * * The residual path needs this: an explicit `/close` deletes the row, so without * it the inventory would lose every trace of an unproven credentialed subtree. */ export declare function containmentSessionIds(dataDir?: string): string[]; /** * Does this session still have a tree we cannot prove is gone? * * This is the predicate the device-isolation blocker hangs off. It THROWS on an * unreadable store rather than answering false. */ export declare function hasUnprovenContainment(sessionId: string, dataDir?: string): boolean; /** * Release every durable handle a REBOOT has provably killed, and report what * stayed. Run once per daemon boot, BEFORE the device-isolation activation * inventory is built. * * Why this exists (round-11 P1-1): `proveContainmentQuiescent` gained a * boot-id-changed branch for cgroup handles, but its only production callers are * live-worker teardown and workerless close — NEITHER runs for a session that is * already `closed_with_residual` and exists only as a durable handle. So after a * host reboot (which truly kills the whole tree, migrant included) that handle * lingered forever and device isolation kept synthesising an `activation_blocked` * from it — the new bootId proof had no consumer. This is that consumer: it * enumerates the store and RELEASES only handles whose verdict is * `boot-id-changed`. A same-boot handle, a legacy handle with no bootId, an * unprovable handle, or a still-live cgroup are all RETAINED. * * Fail-CLOSED throughout: an unreadable store, an unreadable /proc, or a handle * whose proof throws leaves that handle in place. Releasing on a "cannot tell" * is exactly the unblock this whole module refuses. */ export declare function reconcileContainmentHandlesOnBoot(opts?: { procRoot?: string; dataDir?: string; }): { released: number; retained: number; storeUnreadable: boolean; }; /** * Is this verdict strong enough to FORGET the tree, and how much stays behind? * * This is the production gate on `boundaryProof`. It exists because a * `proven: true` verdict is NOT one thing: on a cgroup host it is kernel state, * on a plain Linux host it can be nothing more than "the scan saw nobody", and * those two must not share a code path. * * Truth table, all of it pinned by unit tests: * * verdict evidence boundaryProof release residual * ---------------- -------------------- ------------- ------- -------- * proven, weak boot-id-changed true yes null * proven, cgroup boot-id-changed true yes null * proven, cgroup cgroup-empty FALSE NO deviceIsolation * proven, cgroup cgroup-zombie-only FALSE NO deviceIsolation * proven, weak scan-clean FALSE NO deviceIsolation * not proven n/a false NO deviceIsolation * * Only a reboot (boot-id-changed) authorises forgetting a tree — for BOTH handle * kinds since the strong handle gained a stamped bootId (a reboot kills the * whole tree, sibling-migrant included, and resets cgroupfs). A cgroup being * empty is NOT a boundary proof — a same-UID process can migrate itself out of * the leaf, invisible to the leaf-down read — so an emptiness verdict behaves * exactly like a weak scan-clean: it lets the caller stop re-signalling * (`signalsStopped: true`) and lets the SESSION close, but authorises nothing * else. The handle stays in the durable store, so `hasUnprovenContainment` keeps * answering true and the device-isolation blocker survives the close. */ export declare function containmentReleaseDecision(verdict: QuiescenceVerdict): ContainmentReleaseDecision; /** * Release a handle — the ONLY removal path, and it demands the proof. * * Taking the verdict (rather than a boolean, or nothing at all) is deliberate: it * makes "clear the blocker without proving quiescence" unrepresentable at the type * level, which is the invariant this whole module exists to enforce. A caller * holding a `proven: false` verdict has no way to spend it here. * * The ONE evidence that authorises release is `boot-id-changed`, for either * handle kind (a strong handle carries a stamped bootId since round 10). * Emptiness verdicts (cgroup-empty / cgroup-zombie-only / scan-clean) resolve to * `releaseAuthorised: false` and return below with the handle and the * device-isolation blocker retained — cgroup emptiness is not a boundary proof * (a same-UID process can migrate out of the leaf; see * containmentReleaseDecision). No cgroup directory is reclaimed on the release * path: `boot-id-changed` means the host rebooted and cgroupfs came back empty, * so there is nothing to remove. Within a boot the tree is killed via * `cgroup.kill` during teardown and the handle waits for reboot or operator * revoke. */ export declare function releaseContainmentHandle(verdict: QuiescenceVerdict, dataDir?: string): ContainmentReleaseDecision; /** * OPERATOR OVERRIDE: drop handles without quiescence proof. * * releaseContainmentHandle is deliberately unreachable without a proven verdict, * and on a non-cgroup host a weak handle's scan-clean is never a boundary proof * — so after one mojo session runs and closes there, its handle (and with it the * whole-machine device-isolation `activation_blocked` 409) persists FOREVER, * with no operational path out short of hand-editing the ledger JSON. An * `unprovable` handle is like that by design. Both are correct fail-closed * defaults and both still need an explicit, auditable exit. * * This is that exit, and only for a human operator (the `botmux mojo-containment * revoke` command): it removes the named handles while logging exactly what was * dropped, so the decision that "these trees are acceptable to forget" is a * recorded human judgement, never something the runtime can reach on its own. * Nothing in daemon/worker code may call this. */ export declare function revokeContainmentHandles(sessionId: string, opts?: { handleKey?: string; dataDir?: string; auditNote?: string; }): { removed: ContainmentHandle[]; remaining: ContainmentHandle[]; }; /** * Executing (non-zombie) pids anywhere in a cgroup handle's subtree, for the * operator revoke safety gate. `unreadable` is the fail-closed signal — the gate * must surface it rather than read "cannot enumerate" as "nobody there". */ export declare function cgroupHandleLiveMembers(handle: StrongContainmentHandle, opts?: { procRoot?: string; }): { live: number[]; unreadable: boolean; }; /** * Hand every outstanding handle of a session to a new worker generation. * * Inheritance is a UNION and it is unconditional: replacement does not prove * anything about the old tree, so the new generation becomes responsible for * proving it later. The generation stamp is refreshed for logging while the * IDENTITY fields (cgroup path, pid/boot/starttime, nonce) are preserved * verbatim — rewriting those would invent a handle that proves nothing about the * tree actually left behind. * * Nothing is removed here, so a crash mid-inheritance is safe: the handles are * still recorded under the same session id. */ export declare function inheritContainmentHandles(sessionId: string, nextGeneration: number, dataDir?: string): ContainmentHandle[]; /** * Map a containment verdict onto ProcTree's `TurnQuiescence`. * * This function is the single place in the codebase allowed to mint * `{ kind: 'contained-proven', boundaryProof: true }`, and it does so for exactly * ONE evidence — `boot-id-changed`. That is the whole point of the contracts * meeting here: * * - `quiescenceFromScan()` can never produce `boundaryProof: true`; a clean * /proc scan is a diagnostic signal, because a descendant that both setsids * AND scrubs its own environ evades enumeration entirely. * - An empty (or zombie-only) `cgroup.procs` is ALSO only a diagnostic signal, * NOT a boundary proof: mojo runs at the daemon's UID and cgroup v2 lets a * same-UID process migrate itself out of the leaf, invisible to the leaf-down * read. cgroup emptiness is good for stopping signals and for killing * (cgroup.kill), never for dropping device isolation. * - `boot-id-changed` is the only unforgeable release: a reboot kills the whole * tree, migrant included. It applies to a weak handle and to a cgroup handle * once its bootId is stamped. * * So a STRONG (cgroup) proven-empty verdict maps to `diagnostic-clean`, exactly * like a weak `scan-clean`, and `containmentReleaseDecision` refuses to authorise * removal for either — the handle stays in the durable store and the blocker * survives the close. The gate lives in `containmentReleaseDecision`, a real * production consumer. */ /** * The ONE place a `boundaryProof: true` TurnQuiescence is constructed. * * Round 4 claimed this property in a comment while three separate sites minted * the value, which is why the claim was rejected as unverifiable. It is now * structural: `boundaryProof: true` is minted here and in the release-decision * gate (`containmentReleaseDecision`), and nowhere else — `git grep -n * "boundaryProof: true" -- src/` returns those two sites plus type definitions. * It is exported for the backend, which decides WHETHER a proven boundary applies * but must not decide what one looks like. */ export declare function containedProvenQuiescence(): TurnQuiescence; export declare function containmentQuiescence(verdict: QuiescenceVerdict): TurnQuiescence; /** * Strongest quiescence statement available for a session, across ALL of its * outstanding handles. * * Semantics are intentionally pessimistic, because a session is only as contained * as its WEAKEST outstanding tree: * - any handle that is not proven → that handle's non-proof is the answer * - no handles at all → `diagnostic-clean`; nothing is recorded, * but "nothing recorded" is not a kernel-level boundary proof either * - every handle proven, at least one only diagnostically → `diagnostic-clean` * - every handle proven WITH a boundary proof → `contained-proven` * * A store that cannot be read THROWS (via `containmentHandles`), so an * unreadable store can never present itself as a clean session. */ export declare function sessionContainmentQuiescence(sessionId: string, prove: (handle: ContainmentHandle) => QuiescenceVerdict, dataDir?: string): TurnQuiescence; //# sourceMappingURL=mojo-containment.d.ts.map