/** * sandbox.ts, per-command OS-level execution boundary for the exec tool, * backed by bubblewrap (`bwrap`) on Linux. * * GOAL. Shrink the approval tail: a command that runs inside a real OS boundary *, workspace writable, the rest of the filesystem read-only, /tmp isolated, * network off unless explicitly allowed, is far less able to do harm outside * that boundary, so the permission layer can auto-allow it (see * runtime/permissions/sandbox-policy.ts) instead of prompting. This module is * the RUNNER half: honest availability detection, pure bwrap-argv construction, * and a per-command plan. It composes with the existing credential-env scrub * (credential-env.ts) rather than replacing it, the scrubbed env is what bwrap * hands to the child. * * HONESTY. Detection never claims a boundary it cannot deliver. No bwrap (or a * non-Linux host) → the feature reports unavailable with a stated reason and the * caller runs the byte-for-byte-unchanged non-sandboxed exec path. When bwrap is * present but a host-level probe cannot confirm that `--unshare-net` yields a * real, empty network namespace (e.g. unprivileged user namespaces disabled), * the plan says network isolation is `unknown` rather than claiming containment. * * NOT a permission decision and NOT the frozen catastrophic block. The * unconditional catastrophic block (rm -rf /, dd to a device, mkfs, fork bomb …) * is enforced elsewhere and stays in force identically INSIDE the sandbox, a * boundary is not a licence for a catastrophic command. */ import type { ExecCommandResult } from './schema.js'; /** Operator-facing per-command exec sandbox configuration (`sandbox.*`). */ export interface ExecSandboxConfig { /** Master switch. Default false, the sandbox is off unless explicitly enabled. */ readonly enabled: boolean; /** * Command base names (e.g. `curl`, `git`, or `*` for all) whose network access * is re-enabled inside the boundary as a NAMED escalation. Empty → the sandbox * disables network for every command. */ readonly egressAllowlist: readonly string[]; /** * Absolute paths outside the workspace that are bound writable into the * boundary as a NAMED escalation. Empty → only the workspace (and an isolated * /tmp) is writable. */ readonly workspaceWritable: readonly string[]; } /** The honest, host-probed availability of the exec sandbox backend. */ export interface SandboxAvailability { readonly available: boolean; readonly backend: 'bubblewrap' | 'none'; /** Resolved `bwrap` path when available. */ readonly bwrapPath?: string | undefined; /** Stated reason, a diagnosis when unavailable, a one-line summary when available. */ readonly reason: string; /** * Whether a `--unshare-net` boundary is trustworthy on THIS host. False when * bwrap is present but a trivial net-unshare probe did not succeed (so a * "network disabled" claim would be unproven), surfaced as `network: 'unknown'`. */ readonly networkIsolationGuaranteed: boolean; } /** * The raw host-probe inputs detection reasons over. Kept as plain data so * {@link detectSandboxAvailability} is pure and unit-testable; production wires * the real probe in {@link probeSandboxHost}. */ export interface SandboxHostProbe { /** `process.platform`. */ readonly platform: string; /** Resolved `bwrap` path, or null when not on PATH. */ readonly bwrapPath: string | null; /** A trivial `bwrap --ro-bind / / /bin/true`-style run exited 0. */ readonly bwrapWorks: boolean; /** A trivial `bwrap --unshare-net --ro-bind / / /bin/true`-style run exited 0. */ readonly netUnshareWorks: boolean; } /** * Decide availability from a host probe. Pure. macOS and any non-Linux host are * reported unavailable this release (bubblewrap is Linux-only and no * sandbox-exec equivalent is wired), never faked. */ export declare function detectSandboxAvailability(probe: SandboxHostProbe): SandboxAvailability; /** Pure inputs for {@link buildBwrapArgv}. */ export interface BwrapArgvInput { readonly bwrapPath: string; /** Absolute workspace path, bound read-write. */ readonly workspaceDir: string; /** Absolute working directory for the child (chdir'd into the boundary). */ readonly cwd: string; /** Extra absolute paths bound read-write. */ readonly writableExtras: readonly string[]; /** When true, the child keeps host network; when false, `--unshare-net`. */ readonly networkEnabled: boolean; /** When set, the home directory is masked with a tmpfs (hidden, not just read-only). */ readonly maskHomeDir?: string | undefined; } /** * Construct the bwrap argument vector to PREPEND to `['/bin/sh','-c',cmd]`. Pure. * * The system root is bound read-only, /tmp and (optionally) $HOME are masked with * fresh tmpfs, then the workspace and any writable extras are bound read-write * LAST so they win over the read-only root even when nested under it. Network is * unshared unless explicitly enabled. The env is NOT set here, the caller hands * bwrap the already-credential-scrubbed environment, which bwrap passes through. */ export declare function buildBwrapArgv(input: BwrapArgvInput): string[]; /** Where the boundary sits on network access for a given command. */ export type SandboxNetworkState = 'disabled' | 'enabled' | 'unknown'; /** The resolved per-command plan the exec runtime acts on. */ export interface ExecSandboxPlan { /** True when the command will actually run inside a bwrap boundary. */ readonly sandboxed: boolean; /** argv to prepend to `['/bin/sh','-c',cmd]`; empty when not sandboxed. */ readonly argvPrefix: string[]; /** One-line human summary of the boundary (or why there is none). */ readonly boundary: string; /** Network posture for this command inside the boundary. */ readonly network: SandboxNetworkState; /** Named host-access grants applied to this run (network, writable extras). */ readonly escalationsGranted: string[]; /** * Whether $HOME was masked with a tmpfs for this run. Recorded so the context * note claims the mask only when it actually applied, a note that overstates * the boundary is the same defect as one that hides it. * * Optional so adding it does not break a consumer that builds a plan itself; * absent is read as "not masked", which is the claim-nothing default. */ readonly homeMasked?: boolean | undefined; /** Present when the sandbox was requested but the host cannot provide it. */ readonly unavailableReason?: string | undefined; } /** Inputs to {@link resolveExecSandboxPlan}. */ export interface ResolveSandboxPlanInput { readonly config: ExecSandboxConfig; readonly availability: SandboxAvailability; /** Whether the exec-sandbox capability gate is on (sandbox.enabled). */ readonly featureEnabled: boolean; readonly command: string; readonly workspaceDir: string; readonly cwd: string; readonly homeDir?: string | undefined; } /** * Resolve the per-command sandbox plan. When the capability gate is off, the config * switch is off, or the host cannot provide a boundary, returns a not-sandboxed * plan (the caller then runs today's unchanged exec path). Otherwise returns the * bwrap argv prefix plus honest boundary/network/escalation metadata. */ export declare function resolveExecSandboxPlan(input: ResolveSandboxPlanInput): ExecSandboxPlan; /** * The resolved sandbox context the exec runtime threads per call: the config, * the host availability, and whether the graduation-gated flag is on. Null on a * createExecTool with no sandbox wiring, then every command runs the unchanged * non-sandboxed path. */ export interface ExecSandboxRuntime { readonly config: ExecSandboxConfig; readonly availability: SandboxAvailability; readonly featureEnabled: boolean; readonly homeDir?: string | undefined; /** * Broker a sandbox host-access escalation ask (network, host-privilege * escalation) through the approval broker before the command runs. Wired at * the composition root to the sandbox-escalation seam. Returns true when * approved. When absent, escalations are not asked (today's behavior); the * frozen catastrophic block is enforced independently regardless. */ /** * Invoked each time a command actually runs inside the boundary. Wired at * the composition root to the announce-once containment receipt ("commands * now run contained; escalations will ask"), the announcer keeps the * once-semantics, this seam just reports the runs. */ readonly onSandboxedRun?: (() => void) | undefined; readonly requestEscalation?: ((input: { readonly command: string; readonly escalations: readonly string[]; readonly boundary: string; readonly policyReasons: readonly string[]; readonly workingDirectory?: string | undefined; }) => Promise) | undefined; } /** * Resolve the per-command plan from a threaded runtime context. Returns null * when there is no sandbox wiring at all, so the caller can skip both the argv * wrapping and the result metadata entirely (byte-for-byte today's behavior). */ export declare function resolveRuntimeSandboxPlan(sandbox: ExecSandboxRuntime | null, command: string, workspaceDir: string, cwd: string): ExecSandboxPlan | null; /** * Broker a sandbox host-access escalation ask through the injected * `requestEscalation` seam BEFORE the command runs. Returns the named * escalations when the ask was DENIED (the caller then denies the command), or * null when there was nothing to ask or the ask was approved. The frozen * catastrophic block is enforced independently (guardExecCommand) and is * untouched here, this only ever gates the host-access escalation, never the * command class. */ export declare function brokerSandboxEscalation(sandbox: ExecSandboxRuntime | null, plan: ExecSandboxPlan | null, command: string, workingDirectory: string): Promise<{ deniedEscalations: string[]; } | null>; /** * Build the standing one-line context note for a run inside the boundary. * * The note exists because the boundary's world differs from the host's in ways * that look identical to fact when you only see the output: a fresh network * namespace whose `127.0.0.1` is its own, not the host's; a read-only * filesystem outside the workspace; a masked /tmp and $HOME; and narrower * device and process visibility. A probe that found no Bluetooth adapter and no * daemon on `127.0.0.1:3421` was read as the user's machine lacking both. * * Naming the isolation is only half of it. A model told the daemon is * unreachable and nothing else will keep reaching for it, a second curl, then * systemctl, then a port check, because the note ruled out the route it had * without naming another. So the note names the tools that DO answer the * question from outside the boundary, `goodvibes_context` and * `goodvibes_settings`, which is what turns a dead end into a next step. * * It names the isolation, the way through it, and the one action that changes * it, and stops. It is not a lecture and it is not repeated per line, one * field, once per result. */ export declare function buildSandboxNote(plan: ExecSandboxPlan): string; /** * Attach sandbox metadata to an exec result. Stays quiet (returns the result * unchanged) when there is no plan, or when the sandbox is off entirely and the * command simply ran unsandboxed, metadata appears only when the sandbox was * active OR was requested-but-unavailable (the honest-unavailable receipt). */ export declare function attachSandboxMeta(result: ExecCommandResult, plan: ExecSandboxPlan | null): ExecCommandResult; /** * Probe the real host for bubblewrap availability. Impure (spawns `bwrap` * trivially, bounded to a short timeout); the pure {@link detectSandboxAvailability} * turns the result into an {@link SandboxAvailability}. Non-Linux short-circuits * without spawning. This is the wiring production uses; unit tests exercise * detection with fabricated probes instead of spawning. */ export declare function probeSandboxHost(): SandboxHostProbe; //# sourceMappingURL=sandbox.d.ts.map