/** * Per-bot local read isolation (v2) — the CLI-agnostic core. * * Model (HYBRID): each isolated bot's CLI data is relocated into its own * BOT_HOME (`/bots/`, via CLAUDE_CONFIG_DIR / CODEX_HOME), * then the whole CLI process is wrapped in an OS sandbox (macOS Seatbelt via * `sandbox-exec -f `) that denies reads of: the GLOBAL CLI data dirs, * system credential stores, and every cross-bot-sensitive part of ~/.botmux — * with the bot's OWN slice re-allowed by carve-outs. The wrapped CLI bypasses * its own built-in sandbox, so the outer Seatbelt profile is the sole enforcer * (covers the main process + every Bash subprocess — no escape). * * This module is pure (no fs / no spawn) so it is fully unit-testable and * shared across adapters; the worker resolves the impure inputs (realpath, * platform, adapter capability) and emits the profile. * * Threat model: a semi-trusted Feishu user driving bot A's agent must not be * able to read bot B's session data or credentials (bots.json is the full * multi-bot cred file; each bot's lark-cli config holds its app secret in * plaintext). See the design doc for the two-layer rationale. */ import type { SessionProbe } from '../backend/types.js'; /** Normalize a path for the deny/allow lists: require ABSOLUTE, strip trailing * slashes, reject `..` traversal. Returns null for anything unusable so the * caller drops it (a silently-ignored relative path is a fail-open trap). * NOTE: symlink resolution (realpath) is the caller's job — this is pure. */ export declare function normalizeIsolationPath(p: string): string | null; /** Path of the per-bot `botmux send` credential file the worker writes under read * isolation. Lives INSIDE the bot's BOT_HOME ({@link botHomePath}) — the same * per-bot private storage as its redirected CLI data — so the bot reads its OWN * while every OTHER bot's is already covered by the whole-BOT_HOME deny (no * separate per-file deny needed). This makes BOT_HOME the single private-storage * primitive for any per-bot secret (send cred, future github token, …). The * secret reaches `botmux send` only through this file — never env/argv — so it is * not exposed to sibling bots via `ps aux` / `tmux show-environment`. * * Takes SESSION_DATA_DIR (what every caller has) and derives BOTMUX_HOME as its * parent — the SAME definition the worker uses for BOT_HOME * (`botHomePath(dirname(SESSION_DATA_DIR))`). Centralizing the derivation here * keeps worker-write / CLI-read / deny in lock-step even for a customized * SESSION_DATA_DIR. */ export declare function sendCredFilePath(sessionDataDir: string, appId: string): string; export declare function assertSafeAppId(appId: string): string; /** A bot's private home under BOTMUX_HOME: `/bots/`. Holds the * bot's redirected CLI config/transcripts/memory (CLAUDE_CONFIG_DIR=/claude, * CODEX_HOME=/codex). The ONLY thing under BOTMUX_HOME v2 re-allows. */ export declare function botHomePath(botmuxHome: string, appId: string): string; /** * Minimal read carve-outs needed to launch a CLI whose executable itself lives * under a globally denied data root. The standalone Codex installer exposes * `~/.local/bin/codex` as a symlink through * `~/.codex/packages/standalone/current`; allowing only the final canonical * binary is insufficient because Seatbelt must read the intermediate `current` * symlink while resolving execvp(). Re-open the executable package tree only — * auth.json, config.toml, sessions and the rest of ~/.codex remain denied. * * Inputs must already be canonicalized by the worker (this module stays pure). */ export declare function buildCliExecutableReadCarveOuts(input: { homeDir: string; cliId: string; resolvedBin: string; }): string[]; /** Host device-authority files must never be visible to a chat-driven CLI. * New credentials live below DEVICE_AUTHORITY_DIRECTORY; the exact legacy * files remain covered for upgrades from older layouts. */ export declare const HOST_DEVICE_CREDENTIAL_FILES: readonly ["platform.json", "device.json", "device-enroll-pending.json"]; export declare const DEVICE_CREDENTIAL_ISOLATION_MARKER_BASENAME = ".device-credential-isolation"; /** Fixed host marker; deliberately independent of SESSION_DATA_DIR and child env. */ export declare function deviceCredentialIsolationMarkerPath(homeDir: string): string; /** Match current atomic-write sidecars and legacy backups as well as the * dedicated authority directory. */ export declare function isCredentialIsolationReservedBasename(name: string): boolean; export declare function credentialIsolationRequired(input: { markerExists: boolean; deviceCredentialExists: boolean; }): boolean; export type CredentialOnlyIsolationGate = { required: false; mode: 'off'; } | { required: true; mode: 'remote-bypass'; } | { required: true; mode: 'covered'; } | { required: true; mode: 'seatbelt' | 'bwrap'; } | { required: true; mode: 'blocked'; failClosedReason: string; }; /** Mandatory device-credential isolation is independent of the optional bot * sandbox toggle: once enrolled, every local child must be confined. */ export declare function evaluateCredentialOnlyIsolationGate(input: { markerExists: boolean; deviceCredentialExists: boolean; remoteBackend: boolean; platform: string; mechanismAvailable: boolean; fullIsolationCoversCredentials: boolean; }): CredentialOnlyIsolationGate; export interface CredentialIsolationContext { homeDir: string; botmuxHome: string; defaultBotmuxHome?: string; } /** Legacy credential-only Seatbelt/bwrap rule shape. Full fs-policy sessions * consume the same authority paths through buildFsPolicy instead. */ export declare function buildCredentialIsolationRules(ctx: CredentialIsolationContext): { roots: string[]; denyPaths: string[]; denyRegexes: string[]; denyWritePaths: string[]; denyWriteRegexes: string[]; denyWriteLiterals: string[]; }; /** * Decide whether read isolation is enabled for a session, or fail-closed. * Pure: the caller resolves the impure inputs. This is the SINGLE decision * point — the worker computes it once and uses it for BOT_HOME redirection, * provisioning, and the Seatbelt wrapper alike. * - not configured → `{ enabled: false }` (no error). * - configured but unenforceable → `{ enabled: false, failClosedReason }` — the * caller MUST refuse to start the session rather than run unisolated. * - all satisfied → `{ enabled: true }`. */ export declare function evaluateReadIsolationGate(opts: { configured: boolean; adapterSupports: boolean; wrapperCliSet: boolean; /** process.platform — read isolation is enforced by macOS Seatbelt (sandbox-exec) * OR Linux bwrap masks; unsupported elsewhere (fail-closed rather than run * unisolated). NOTE: on Linux the masks ride the bwrap file sandbox, so the caller * must ensure the sandbox is on (see readIsoConfigured in worker.ts). */ platform: string; /** SESSION_DATA_DIR present (BOT_HOME + profile paths derive from it). */ sessionDataDirSet: boolean; }): { enabled: boolean; failClosedReason?: string; }; /** Legacy allow-default profile retained only for mandatory credential-only * confinement when the full fs-policy sandbox is disabled. */ export declare function buildSeatbeltProfile(denyPaths: string[], allowPaths?: string[], finalDenyPaths?: string[], traverseDirs?: string[], denyRegexes?: string[], writeSandbox?: { allowWritePaths: string[]; allowWriteRegexes?: string[]; denyWritePaths: string[]; denyWriteRegexes?: string[]; }, protectedWrites?: { denyWritePaths: string[]; denyWriteRegexes?: string[]; denyWriteLiterals?: string[]; }): string; export declare const ISOLATION_PANE_MARKER_VERSION = 11; export type IsolationCapability = 'credential' | 'read' | 'write'; export interface IsolationPanePolicyInput { readIsolation: boolean; writeSandbox: boolean; readDenyExtraPaths?: readonly string[]; writeAllowExtraPaths?: readonly string[]; readOnlyExtraPaths?: readonly string[]; readWriteExtraPaths?: readonly string[]; workingDir?: string; homeDir?: string; osUserHomeDir?: string; botmuxHome?: string; sessionDataDir?: string; currentAppId?: string; cliId?: string; resolvedBin?: string; } /** Deterministic fingerprint of effective Darwin Seatbelt inputs that can * change between worker forks while the pane survives. Arrays are normalized * as sets because rule order does not change their final deny semantics. */ export declare function isolationPanePolicyDigest(input: IsolationPanePolicyInput): string; export declare function isolationPaneMarkerContent(bootId: string, capabilities: readonly IsolationCapability[], policy?: { originChannelId: string; readIsolation: boolean; writeSandbox: boolean; policyDigest: string; }): string; export declare function isolatedPaneOriginChannel(markerContent: string | null | undefined): string | undefined; /** Directory holding per-session persistent-pane provenance files. */ export declare function persistentPaneProvenanceDir(runtimeDataDir: string): string; /** ISOLATION marker path (`.boot`) — stamped for a policy-ON sandboxed pane. */ export declare function isolationPaneMarkerPath(runtimeDataDir: string, sessionId: string): string; /** TOMBSTONE path (`.policy-off`) — positively proves a live pane was * cold-spawned by the current NO-SANDBOX policy (see * {@link evaluatePersistentPaneMigration}). Distinct filename so it survives / * is cleared independently of the isolation marker. */ export declare function policyOffTombstonePath(runtimeDataDir: string, sessionId: string): string; /** Tombstone body: a self-describing, version-stamped generation proof. Content * is diagnostic-bearing but its PRESENCE-as-valid (not equality to any live boot * id) is the reattach signal — a legitimate policy-off pane warm-reattaches * across daemon restarts, so binding to the current boot id would cold-spawn it * every restart. bootId is kept only for diagnostics. * * `state:'committed'` is REQUIRED for authorization: a proof is written first as * PENDING (see {@link provenancePendingContent}) before the pane is created, and * only rewritten to committed once the fresh generation is attributably * established (see the generational-race guard in worker.ts). A pending record * never authorizes a reattach — {@link policyOffTombstoneValid} rejects it. */ export declare function policyOffTombstoneContent(bootId: string): string; /** * PENDING provenance body: written to the FINAL proof path BEFORE `backend.spawn()` * for a predicted-fresh persistent launch, then rewritten to the committed body * only after the fresh generation is attributably established. It carries a random * `nonce` (compare-before-replace at commit time, so a superseded generation's * deferred callback can't overwrite a newer pending) and, deliberately, NEITHER a * committed `state` NOR the structural fields the validators require — so both * {@link policyOffTombstoneValid} and {@link isolatedPaneReattachSafe} reject it * outright. Its on-disk PRESENCE still drives the conservative guard: a pending * file means "this system KNOWS a generation's attribution is incomplete", which * is STRONGER than legacy provenance and dominates the migration scope (a live * pane with a pending proof is always killed + cold-spawned; see * {@link evaluatePersistentPaneMigration}). */ export declare function provenancePendingContent(nonce: string): string; /** Extract the pending nonce for the compare-before-replace at commit time. * Returns the nonce string only for a well-formed pending record read from a * secure 0600 file; null otherwise (so a garbage/committed/absent file never * matches a live launch's nonce). */ export declare function provenancePendingNonce(content: string | null | undefined): string | null; /** * Validate a policy-off tombstone body (already securely read from a real 0600 * file by the caller — see readManagedOriginAuthorityFile). Returns true only for * a well-formed CURRENT-version `policyOff:true` record with a non-empty string * bootId. bootId is NOT compared to the live daemon boot id (a legit policy-off * pane must reattach across restarts); it only has to be present + a string, so a * blank/garbage/structurally-wrong tombstone cannot authorize a warm reattach. * Mirror of {@link isolatedPaneReattachSafe}'s fail-closed parse discipline, but * for the opposite polarity: here VALID authorizes reattach. * * A `state:'pending'` record is explicitly rejected (an incomplete generation * proof must never authorize). `state` is now REQUIRED to equal 'committed': the * v11 version bump means every legitimate proof carries it, so a missing/other * state is refused (this is what forces a pre-v11 no-state marker — possibly * washed onto a late-winner pane under the old pre-spawn-write path — to * cold-spawn once instead of being trusted). */ export declare function policyOffTombstoneValid(content: string | null | undefined): boolean; /** * Decide whether a live persistent pane (tmux/zellij/herdr) may be reattached for * an isolated bot. Isolation is injected at CLI *spawn* time (the Seatbelt * wrapper) and lives on the RUNNING process, so a pane that was spawned isolated * STAYS isolated for its whole lifetime — including across daemon restarts (the * sandbox is on the CLI process, independent of the daemon). * * We stamp a versioned marker file when we spawn an isolated CLI. A reattach is * safe only when the live process was launched with the current policy version. * This matters during security upgrades: a legacy Seatbelt process keeps its old * permissions across daemon restarts and must be cold-spawned under the new * profile. The boot id remains diagnostic and is not compared across restarts. */ export declare function isolatedPaneReattachSafe(markerContent: string | null | undefined, expected?: readonly IsolationCapability[] | { requiredCapabilities: readonly IsolationCapability[]; exactCapabilities?: boolean; readIsolation?: boolean; writeSandbox?: boolean; requireOriginChannel?: boolean; policyDigest?: string; }): boolean; /** * Persistent-pane (tmux/zellij/herdr/zmx) reattach migration decision — the pure * state machine behind the worker's stale-pane guard. Isolation is injected at * CLI *spawn* time and lives on the RUNNING process, so a pane that survives a * daemon restart keeps whatever confinement it was born with. This function * decides, from persisted evidence + the current policy, whether the live pane * may be warm-reattached or must be killed + cold-spawned under the new policy. * * Two provenance files live under `/read-isolation/`: * · `.boot` — ISOLATION marker: written (best-effort) when a policy-ON * (sandboxed) pane is spawned. Its capabilities/policy are * version-checked by {@link isolatedPaneReattachSafe}. * · `.policy-off` — TOMBSTONE: written when a policy-OFF (no-sandbox) pane * is cold-spawned, positively proving "this generation was * created by the current no-sandbox policy". * * Why a tombstone and not just "no isolation marker": the isolation stamp is * BEST-EFFORT (its write is wrapped in try/catch and the spawn proceeds anyway), * so "no marker" does NOT prove the live process was never isolated — a sandboxed * pane whose stamp write lost a race/perm/disk error looks identical. Under * policy-OFF we therefore require POSITIVE, VALIDATED proof (a tombstone that * passes secure-read + schema check) to warm-reattach; any other shape (isolation * marker present, tombstone missing/invalid, or NEITHER file) is treated as * possibly-still-confined and killed. Absence is never trusted as safe. * * Scope is split by policy direction: * · policy ON (file sandbox OR credential-only `credential` cap): the exact- * capability/policy check runs on EVERY persistent backend — credential-only * panes exist on zellij/herdr/zmx too, so this must NOT be tmux-scoped. * · policy OFF migration arm: scoped to `noTransport && isolationCapableBackend` * (only no-transport tmux was ever file-force-isolated by the removed rule). * An ordinary transport chat / non-tmux backend is never subjected to the * tombstone requirement — no false kills — though a DEAD pane's stale * provenance is still cleared so it cannot mislead a later decision. * * Existence flags MUST come from no-follow existence probes (a planted/tampered * leaf that fails to parse still counts as present, so it can never be used to * force a silent reattach). `policyOffTombstoneValid` is the secure-read result. * * Pane liveness is TRI-STATE (`paneProbe`: exists | missing | unknown), NOT a * boolean. `unknown` (the backend could not answer) is never collapsed into * "dead": a still-alive, still-confined pane whose probe is momentarily `unknown` * would otherwise have its provenance cleared and be cold-spawned around, silently * downgrading confinement. On `unknown` the machine returns * `refuse-inconclusive-probe` (fail-closed) whenever anything is at stake — policy * ON, in the policy-off migration scope, or ANY provenance on disk — and only * `skip`s when a wholly unconcerned session (policy OFF, out of scope, no * provenance) sees probe flakiness, so an ordinary chat never fails to start. * Only an authoritative `missing` clears stale provenance / cold-spawns. */ export type PersistentPaneMigrationInput = { /** Current-spawn isolation capabilities (empty ⇒ policy OFF this spawn). May be * non-empty on ANY persistent backend — `credential` is pushed for enrolled * hosts independent of the file sandbox, and its wrapper applies to * tmux/zellij/herdr/zmx alike. So the policy-ON capability check below is NOT * scoped to tmux. */ appliedIsolationCapabilities: readonly IsolationCapability[]; /** Backend can carry a FILE sandbox (tmux). Scopes ONLY the policy-off * no-transport migration arm (the removed force-isolation rule only ever * file-sandboxed tmux); policy-ON capability checks run on every backend. */ isolationCapableBackend: boolean; /** apiOnly bot OR HTTP-virtual chat — the sessions the old rule force-isolated. */ noTransport: boolean; /** `.boot` exists on disk (no-follow existence — planted/garbage counts). */ isolationMarkerPresent: boolean; /** `.policy-off` tombstone exists on disk (no-follow existence). Triggers * CLEANUP / conservative decisions; does NOT by itself authorize a reattach. */ policyOffTombstonePresent: boolean; /** The `.policy-off` tombstone passed secure-read + schema/version * validation ({@link policyOffTombstoneValid}). ONLY this authorizes a * policy-off warm reattach. */ policyOffTombstoneValid: boolean; /** The persistent pane's liveness probe — TRI-STATE, NOT a boolean. `exists` * and `missing` are authoritative; `unknown` means the probe could not answer * (flaky/unavailable backend). Collapsing `unknown` into "dead" is the bug this * field prevents: a still-alive, still-confined pane whose probe is momentarily * `unknown` must never have its provenance cleared nor be cold-spawned around. * Only an authoritative `missing` proves the pane is gone. */ paneProbe: SessionProbe; /** A PENDING provenance file (marker OR tombstone whose secure-read body parses * as `state:'pending'`) is present on disk. This is STRONGER than legacy * provenance and DOMINATES everything below: it means the system explicitly * knows a generation's fresh-attribution never completed (crash between * pending-write and commit, or a late-flip/collision that was never committed). * A pending file is evaluated FIRST, on ALL backends and BOTH policy directions, * independent of the tmux migration scope — `exists`→kill, `unknown`→refuse, * `missing`→clear. Its no-follow presence also keeps isolationMarkerPresent / * policyOffTombstonePresent true (the file exists), but the pending branch runs * before any of the committed-provenance logic. */ pendingProvenancePresent: boolean; /** * Result of {@link isolatedPaneReattachSafe}(marker, current policy) — only * meaningful when policy is ON. The caller computes it (it needs the parsed * marker + policy digest); passed in to keep this function pure. */ isolationMarkerReattachSafe: boolean; }; export type PersistentPaneMigrationDecision = /** Guard does not apply (nothing to evaluate). */ { action: 'skip'; } /** Live pane matches the current policy → keep the running process. */ | { action: 'reattach'; } /** Live pane's provenance is wrong/unknown → kill, then cold-spawn. Provenance * files are cleared ONLY AFTER the kill is confirmed (clearAfterKill). */ | { action: 'kill-then-cold-spawn'; clearAfterKill: boolean; } /** No live pane, but stale provenance files linger → clear them (verified) then * cold-spawn fresh, so a later restart doesn't misjudge the new pane. */ | { action: 'clear-stale-then-cold-spawn'; } /** The liveness probe is INCONCLUSIVE (`unknown`) in a context where acting would * be unsafe — clearing provenance the pane might still own, or cold-spawning * around a pane a later `exists` probe would warm-reattach unvalidated. The * caller MUST refuse to start rather than guess (fail-closed). Only reached when * the guard is security-concerned; an ordinary transport chat with no provenance * skips on `unknown` instead (no gratuitous start-failures on probe flakiness). */ | { action: 'refuse-inconclusive-probe'; }; export declare function evaluatePersistentPaneMigration(input: PersistentPaneMigrationInput): PersistentPaneMigrationDecision; /** * Injectable side-effect seam for {@link executePersistentPaneMigration}. The * worker supplies real implementations (backend kill, post-kill probe, verified * provenance removal, backend re-selection); tests supply mocks to observe the * ORDER of effects and the "not called" guarantees on each failure path — the * part a pure truth-table cannot cover. */ export type PersistentPaneMigrationEffects = { /** Kill the stale persistent pane. Throw on failure — caller must NOT proceed. */ killStalePane: () => void; /** Probe AFTER the kill; throw (fail-closed) if termination cannot be confirmed. */ confirmPaneGone: () => void; /** Remove BOTH provenance files, each verified-gone; throw if any cannot be * removed (fail-closed — a surviving file would mis-drive the next restart). */ clearProvenanceVerified: () => void; /** Re-select the backend so a stale isReattach=true does not target the pane we * just destroyed. Only called after a confirmed kill + cleared provenance. */ reselectBackend: () => void; /** Refuse to start the session because the liveness probe was inconclusive * (`unknown`) where acting would be unsafe. MUST throw — there is no safe * fall-through. */ refuseInconclusiveProbe: () => never; }; /** * Execute a {@link PersistentPaneMigrationDecision} with strict fail-closed * ordering. Extracted from the worker so the ordering + "stop on failure" * guarantees are unit-testable with injected effects: * * kill-then-cold-spawn : killStalePane → confirmPaneGone → (clearAfterKill? * clearProvenanceVerified) → reselectBackend. * Any throw from killStalePane or confirmPaneGone aborts BEFORE clearing * provenance (evidence is preserved for the retry) and BEFORE reselect. A * throw from clearProvenanceVerified aborts BEFORE reselect (never publish a * new generation while a stale proof lingers). * clear-stale-then-cold-spawn : clearProvenanceVerified only (no live pane to * kill; a throw aborts the spawn). * refuse-inconclusive-probe : refuseInconclusiveProbe (always throws — the probe * was `unknown` where clearing/cold-spawning is unsafe). * NO provenance is touched and NO reselect happens. * reattach / skip : no effects. * * Returns the action taken so the caller can branch (e.g. set warm-reattach). */ export declare function executePersistentPaneMigration(decision: PersistentPaneMigrationDecision, effects: PersistentPaneMigrationEffects): PersistentPaneMigrationDecision['action']; /** * Which kill/probe primitive a persistent-pane teardown must use, so it targets * the EXACT just-launched pane and never a shared host. Pure so the worker's * inline teardown and the migration effects share one behaviorally-tested policy: * * · 'zmx' — identity-verified kill against the frozen managed PID + owned probe. * · 'target' — the recorded PersistentBackendTarget (REQUIRED when one exists): * a herdr isolated/MCP agent lives as `{sessionName:'botmux', * agentName:}` on the SHARED host, so a name-only kill of * 'botmux' would tear down every bot's agent. The target scopes the * kill to this agent. * · 'name' — last-resort name-only kill, ONLY when no target was recorded * (legacy tmux/zellij that own their whole session by name). */ export type PersistentTeardownKillKind = 'zmx' | 'target' | 'name'; export declare function persistentTeardownKillKind(input: { backendType: string; hasBackendTarget: boolean; }): PersistentTeardownKillKind; /** * True when this process is a CLI the worker spawned for a bot under READ * ISOLATION (the sandbox), where `~/.botmux/bots.json` is denied ON PURPOSE (it * holds every sibling bot's app secret). Callers use it to tell that EXPECTED * denial apart from a genuine unreadable-config fault. * * The signal is `BOTMUX_READ_ISOLATION`, which the worker sets (and otherwise * explicitly DELETES) on the child env, gated on `sandboxRequested`. It has to * come from the host; two CLI-side guesses were tried and are both wrong: * * · `SESSION_DATA_DIR` + `BOTMUX_LARK_APP_ID` — injected for EVERY * worker-spawned CLI, sandboxed or not. Matches ordinary bots, so a real * "bots.json is unreadable" fault would be silently downgraded to "there are * no bots" on a normal host. * · existence of `/send-cred.json` — wrong in BOTH directions: a * no-transport (apiOnly) bot has its own copy denied by fs-policy * (`push([`${ctx.botHome}/send-cred.json`], 'deny', 'mandatory')` in the * `!larkTransport` branch), so a genuinely sandboxed bot reads as * not-isolated; and the file is never cleaned up, so flipping a bot from * `sandbox: true` back to `false` leaves a stale one behind that makes an * ordinary CLI look isolated. * * (Both caught in review, 2026-08-03. Do not "simplify" this back to either.) */ export declare function underReadIsolation(): boolean; //# sourceMappingURL=read-isolation.d.ts.map