/** * Linux file sandbox: bwrap DIRECT mode (fs-policy three-tier whitelist). * * Model (2026-07-16 refactor, design doc "botmux 文件沙盒重构方案"): the * sandboxed CLI writes the PROJECT DIRECTLY (same behaviour as an unsandboxed * run inside the policy's readWrite zones) and sees NOTHING outside the * policy's rules — a fresh tmpfs root, only the rule paths bound in. This * replaced the overlayfs+landing model: no mounts to leak, no landing step, * no bridge redirect (the CLI's data dir is a REAL host path). * * The policy is built by the worker (adapters/cli/fs-policy.ts — the single * source of truth for BOTH platforms) and compiled to bwrap argv here. macOS * enforces the SAME policy via Seatbelt (compileToSeatbelt) at the worker's * spawn site — nothing in this module runs on darwin. * * `botmux send` relay: unchanged from the previous model. The sandboxed CLI's * `botmux send` writes a validated request into a per-session outbox; the * daemon-side watcher re-executes the send OUTSIDE the sandbox with real * credentials. No Feishu credential ever enters the sandbox. */ import { isMojoFullyRemote } from './mojo-types.js'; import { type FsPolicy } from '../cli/fs-policy.js'; /** Lightweight bwrap used when only the one-way device credential boundary is * required and the full file sandbox is off. */ export declare function buildCredentialOnlySandboxArgs(input: { hideDirectories: string[]; hideFiles: string[]; readonlyPaths?: string[]; privateReadonlyDirectories?: Array<{ parent: string; directory: string; }>; workingDir: string; cliBin: string; cliArgs: string[]; }): string[]; export interface CredentialOnlySandboxSpawn { bin: string; args: string[]; } export type HostCredentialIsolationMechanismProbe = { supported: true; mechanism: 'seatbelt' | 'bwrap'; executable: string; } | { supported: false; mechanism: null; reason: string; }; export declare function probeHostCredentialIsolationMechanism(): HostCredentialIsolationMechanismProbe; export declare function credentialOnlySandboxAvailable(): boolean; export declare function prepareCredentialOnlySandbox(input: { hideDirectories: string[]; hideFiles: string[]; readonlyPaths?: string[]; privateReadonlyDirectories?: Array<{ parent: string; directory: string; }>; workingDir: string; cliBin: string; cliArgs: string[]; }): CredentialOnlySandboxSpawn | null; /** Re-expose trusted executable directories hidden below the fresh /run tmpfs. */ export declare function reexposeRunBinArgs(binPaths: (string | undefined)[]): string[]; /** Is the file sandbox globally forced for this daemon? The real per-bot * BotConfig.sandbox flag is decided by the caller. */ export declare function sandboxEnabled(): boolean; /** Three-state classification of a single bwrap probe run. The distinction is * load-bearing: only a `clean-nonzero` (bwrap RAN and returned a real verdict) * is evidence about namespace support; a `timeout`/spawn-error/signal is * `inconclusive` and must NEVER be treated as such evidence. */ export type BwrapProbeOutcome = 'success' | 'clean-nonzero' | 'inconclusive'; /** * Pure decision for the DUAL pid-ns probe — extracted for unit testing. * * We must distinguish "this env forbids a fresh /proc mount inside a NEW pid * namespace" (the real nested-sandbox condition → safe to drop --unshare-pid) * from "bwrap is broken / the probe couldn't run" (must NOT degrade). Two * probes, each classified into THREE states (success / clean-nonzero / * inconclusive): * - full = `--unshare-user --unshare-pid --proc /proc …` (real sandbox shape) * - weak = same MINUS `--unshare-pid` * * Degrade IFF `full === 'clean-nonzero' && weak === 'success'` — i.e. bwrap * DEFINITIVELY rejected the run WITH a new pid namespace but ACCEPTED it * WITHOUT one → removing --unshare-pid is precisely the fix (nested signature). * EVERY other combination keeps full isolation (fail-closed): * - full success → no need to degrade * - full inconclusive (timeout / spawn error / signal) → NOT evidence of a * pid-ns restriction, even if weak succeeds → do NOT degrade * - weak not a clean success (nonzero / inconclusive) → bwrap broken for a * reason dropping pid-ns won't fix → do NOT degrade * * Returns whether the host CAN keep --unshare-pid (true = no degrade). */ export declare function pidNsDualProbeCanUnshare(full: BwrapProbeOutcome, weak: BwrapProbeOutcome): boolean; export declare function bwrapCanUnsharePid(): boolean; /** Test-only: reset the cached pid-ns probe. */ export declare function __testOnly_resetPidNamespaceProbe(): void; /** * Whether compileToBwrap should DROP `--unshare-pid` for this spawn. Gated on * BOTH conditions, deliberately conservative (security review): * 1. BOTMUX_CORE_ONLY=1 — core-only synthesizes EXACTLY ONE apiOnly bot * (bot-registry.maybeSynthesizeCoreOnlyConfig), whose worker carries * LARK_APP_SECRET='' (no-transport). There is NO sibling worker holding a * secret in env, so exposing the host pid namespace (a fresh --proc in the * host pid ns enumerates host processes) cannot leak any bot secret via * /proc//environ. On a normal/mixed fleet a sibling transport bot's * worker DOES carry the plaintext secret in env (worker-pool.ts:2404), so * --unshare-pid MUST stay — hence this gate never fires there. * 2. The host actually can't unshare-pid (nested sandbox) — otherwise keep * full isolation; there's no reason to weaken it when it works. * The on-disk credential seal (fs-policy deny masks) is UNCHANGED regardless; * only the pid-namespace defense-in-depth is dropped, and only where it both * (a) can't work and (b) protects nothing. */ export declare function coreOnlyPidNamespaceDegrade(): boolean; /** * Whether a LOCAL sandbox engine applies to this backend at all. * * riff has NO local CLI process to wrap — execution happens entirely in riff's * own remote sandbox. Without that bypass the worker's fail-safe "backend not * sandboxable" hard error would brick every sandbox-enabled bot the moment it * switches to riff. Platform is no longer a factor — fs-policy sandboxes darwin * AND linux. * * mojo is NOT unconditionally remote, and this is the important asymmetry: * MojoBackend spawns the `mojo` binary locally on every turn. Only with * `cloud: true` do the agent's TOOLS run off-box; `cloud` is optional, and * `localDaemon: true` explicitly opts INTO local execution. Treating mojo as * remote regardless would silently skip the local sandbox for a bot that asked * for `sandbox: true` — a fail-OPEN. So a mojo bypass requires proof of remote * execution, and anything else keeps the local sandbox engaged (fail closed). */ export declare function localSandboxApplies(backendType: string, remoteExecution?: { cloud?: boolean; localDaemon?: boolean; wrapperCli?: string; jwtEnv?: string; env?: Record; }): boolean; interface MaskMountEntry { path: string; kind: 'dir' | 'file'; /** Host device + inode captured right after WE created the mountpoint. The * delete-time identity check: only remove if lstat still reports these. */ dev: number; ino: number; } /** Atomically persist the created-mountpoint manifest (0600). Returns false on * failure so the caller can FAIL CLOSED (roll back + abort spawn) rather than * start a session whose pre-created host mountpoints could later leak. */ declare function writeMaskManifest(sessionRoot: string, created: MaskMountEntry[]): boolean; /** Reclaim a list of created-mountpoint entries (the IN-MEMORY truth). Runs on * ALL teardown paths — normal close reads the entries back from the manifest, * spawn-failure rollback passes the in-memory accumulator directly (the * manifest may never have been written). Fail-safe & NON-RECURSIVE: * - rejects any entry whose path is non-absolute, contains `..`, or is a * symlink on disk (a swapped entry can't trick us into deleting elsewhere); * - IDENTITY-BINDS: removes only when the on-disk (dev, ino) still matches what * WE recorded at creation — so a tampered manifest pointing at a pre-existing * victim, or a path whose empty object was replaced after we created ours, * is left untouched; * - a dir is removed with `rmdir` only (throws ENOTEMPTY if the host/a * concurrent process wrote into it → content preserved, never rm -rf); * - a file is `unlink`ed only when it is a regular, zero-byte file. * Entries are processed in order; the caller records them deepest-first so a * child is removed before its now-empty parent. */ declare function reclaimMaskEntries(entries: unknown): void; /** Reclaim the empty deny-mask mountpoints recorded in the persisted manifest, * then (implicitly) the manifest goes with the sessionRoot. Used by the NORMAL * teardown paths (close, reattach-close, stale sweep) — where the manifest was * written successfully. Spawn-failure rollback does NOT use this (it can't * trust a manifest that may never have been written); it passes the in-memory * accumulator to reclaimMaskEntries directly. MUST run BEFORE removing * sessionRoot (which holds the manifest). */ declare function reclaimMaskMounts(sessionRoot: string): void; /** Create a mask mountpoint on the host (all missing ancestors too), pushing * EACH level we actually create — deepest first — into the caller-owned * `sink` accumulator IMMEDIATELY (before attempting the next level), each with * its (dev, ino). This is what lets rollback reclaim partially-created chains: * if a deeper level throws (e.g. ENAMETOOLONG on the leaf), every ancestor we * already made is already in `sink` for the caller's rollback. `kind` applies * to the leaf; ancestors are always dirs. No-op when the leaf already exists * (a pre-existing host path is never our cleanup target). Throws on failure so * the caller can fail closed — with `sink` holding whatever succeeded. */ declare function createMaskMount(leaf: string, kind: 'dir' | 'file', sink: MaskMountEntry[]): void; export declare const __testOnly_maskMounts: { MASK_MANIFEST_NAME: string; createMaskMount: typeof createMaskMount; writeMaskManifest: typeof writeMaskManifest; reclaimMaskMounts: typeof reclaimMaskMounts; reclaimMaskEntries: typeof reclaimMaskEntries; }; export interface DirectSandboxSpawn { /** Replace the CLI binary with this (always 'bwrap'). */ bin: string; /** bwrap args + '--' + original (bin, ...args). */ args: string[]; /** Env overrides to merge into childEnv (HOME, PATH, BOTMUX_SEND_RELAY, proxies). */ env: Record; /** Outbox dir the daemon watcher must service. */ outbox: string; /** Remove the per-session sandbox tree (plain rm — no mounts exist). */ cleanup: () => void; } /** * Build the bwrap DIRECT-mode spawn for a CLI session, or return null when the * runtime deps are unavailable / setup fails (fail-safe: the worker treats * null as a hard error and never silently runs unsandboxed). * * Layout under /sandboxes//: outbox, shimbin, empties. * No overlays, no upper/work dirs — writes inside readWrite zones hit the * real filesystem directly. */ export declare function prepareDirectSandbox(opts: { sessionId: string; dataDir: string; /** Compile-ready policy (canonical + existence-filtered by the worker). */ policy: FsPolicy; /** Child chdir (the canonical project working dir). */ chdir: string; /** Canonical $HOME to set for the child. */ home: string; cliBin: string; cliArgs: string[]; /** Absolute Botmux command paths already persisted in CLI MCP configs. * Bind the worker-generated relay shim at those exact paths so a stale or * tampered host wrapper cannot replace the trusted gateway entry. */ trustedBotmuxCommandPaths?: readonly string[]; /** Worker-owned Unix socket for the credential-bearing MCP Gateway. */ mcpGatewaySocketPath?: string; /** CANONICAL lark-cli data root (the parent of the frozen keystore dir, i.e. * `dirname(larkCliLinuxStore)`) the worker resolved + nearest-ancestor-canonicalized. * Pinned into the child as LARKSUITE_CLI_DATA_DIR so the in-sandbox lark-cli resolves * the SAME keystore the policy denied/carved-out — NOT the lexical env value, which on a * symlinked data root resolves to an unbound path inside the sandbox (ENOENT → auth * breaks) or a namespace the policy never anchored. Absent/empty → * unset in the child (default store, matching the policy's default resolution). */ larkCliDataDir?: string | null; }): DirectSandboxSpawn | null; /** * Re-attach the daemon/worker side to an ALREADY-spawned sandbox session (a * live bwrap'd CLI surviving in a tmux/herdr/zellij pane across a daemon * restart). Only the outbox path is needed back so the watcher keeps servicing * the live CLI's `botmux send`, plus a cleanup that removes the tree at * close/exit. Returns null if the session has no sandbox tree on disk (never * sandboxed). Linux-only, mirrors prepareDirectSandbox's layout. */ export declare function attachSandboxOutbox(opts: { sessionId: string; dataDir: string; }): { outbox: string; cleanup: () => void; } | null; /** * Reclaim leaked per-session sandbox trees (outbox/shim/empties of sessions * that no longer exist) — plain directory residue in the direct model, no * mounts. Guards: never touch an ACTIVE session's tree (it may be suspended, * intending to resume — its outbox must survive) and never touch a tree * referenced by a live process (a reattached pane whose session record was * lost). Safe to call repeatedly: wired at daemon bootstrap AND on a periodic * timer. */ export declare function sweepOrphanSandboxes(dataDir: string, activeSessionIds: Set): void; export interface RelayRequest { contentFile?: unknown; preparedContentFile?: unknown; cardFile?: unknown; attachments?: unknown; videos?: unknown; videoCovers?: unknown; flags?: unknown; originTurnId?: unknown; originDispatchAttempt?: unknown; originCapability?: unknown; } export interface ValidatedRelay { contentName: string; preparedContentName?: string; cardName?: string; attachmentNames: string[]; videoNames: string[]; videoCoverNames: string[]; flags: string[]; originTurnId?: string; originDispatchAttempt?: number; originCapability?: string; } /** * PURE validation of an outbox relay request (schema + flag allowlist only — no * filesystem access, so it's deterministically testable): * - contentFile/preparedContentFile/cardFile/attachments/videos/videoCovers * must be plain basenames (no `/`, `\`, `..`). * - only allowlisted presentation flags pass; any other flag → reject (this * rejects raw `--content-file`/`--session-id`/path flags etc.). * The TOCTOU-safe filesystem read is handled separately by materializeOutboxFile, * NOT here — this function deliberately resolves no paths. */ export declare function validateRelayRequest(req: RelayRequest): { ok: true; value: ValidatedRelay; } | { ok: false; error: string; }; /** * TOCTOU-safe copy of an outbox file (`outbox/`, name already validated as * a plain basename) into a host-private `dest`. Opens with O_NOFOLLOW so a * symlink swapped in by the sandbox AFTER validation is rejected at open time, * and O_NONBLOCK so opening a FIFO/special the sandbox dropped into the (rw-bound) * outbox returns IMMEDIATELY instead of blocking the synchronous watcher tick * forever (a FIFO with no writer would otherwise hang the whole worker event * loop — an agent-triggerable DoS). After open we fstat-reject anything that * isn't a regular file (a FIFO opened O_NONBLOCK|O_RDONLY succeeds but isFile() * is false → rejected here). Reads from the fd (not the path), so the inode can't * be swapped under us. Returns false (reject) on symlink / non-regular / any error. */ export declare function materializeOutboxFile(outbox: string, name: string, dest: string): boolean; /** * Daemon/worker-side outbox watcher. The sandboxed `botmux send` (relay mode) * drops `.req.json`; we validate (validateRelayRequest) and then MATERIALIZE * the content/attachments into a host-private staging dir that is NOT bound into * the sandbox — closing the TOCTOU window where the sandbox could swap an outbox * file for a symlink between check and the host-side read. We then re-exec THIS * build's `send` OUTSIDE the sandbox (full creds) against the private copies, * with the session-id FORCED. This keeps every Lark credential out of the sandbox. */ export declare function buildRelayHostEnv(baseEnv: NodeJS.ProcessEnv, preparedContentFile?: string): NodeJS.ProcessEnv; export declare function startOutboxWatcher(outbox: string, baseEnv: NodeJS.ProcessEnv, sessionId: string, opts?: { /** Host-side authorization for a relay's claimed origin capability. When * absent the relay still runs, but carries NO durable origin — a missing * hook must never let the sandbox promote its own origin fields. */ authorize?: (claim: { capability?: string; }) => { ok: true; origin: { turnId?: string; dispatchAttempt?: number; /** The worker matched an unsettled Codex App ledger entry. The * host child must still find that exact entry before any provider * side effect; terminal settlement/revocation between authorize * and re-exec therefore fails closed instead of degrading to an * ordinary mutable-session send. */ requiresCodexAppLedger?: boolean; }; } | { ok: false; error: string; }; cliPath?: string; }): () => void; export { isMojoFullyRemote }; //# sourceMappingURL=sandbox.d.ts.map