Skip to content

Commit 827234e

Browse files
authored
Merge pull request #3 from Zipstack/sandbox
Ralph two-mechanism sandbox + security-review hardening
2 parents 49aa27d + 53a97a5 commit 827234e

40 files changed

Lines changed: 5439 additions & 136 deletions

‎CLAUDE.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,5 +106,15 @@ This project uses the **mfbt MCP server**, which exposes a virtual filesystem (V
106106
- **Ralph in TUI:** Integrated into main TUI (`r` key), not a standalone app. Ralph runs *in place* inside the unified `FeatureListPanel` (`tui/screens/feature_list.py`) in `#main-content` — there is no separate Ralph panel — with `PreflightModal` for agent checks. Standalone `tui_app.py` and the old `ralph_panel.py` are deleted.
107107
- **Ralph subcommand:** `src/mfbt/commands/ralph/` — orchestrator, display (console), tui_display (Textual adapter), ralph_widgets (TUI widgets), agent runner, progress (API), prompt builder, types.
108108
- **Display duck typing:** `RalphOrchestrator.display` is typed as `Any` — both `RalphDisplay` and `RalphTUIDisplay` are structurally compatible (same display protocol).
109+
- **Ralph sandboxing (two-mechanism, hard-fail — current design):** Exactly **two** sandbox mechanisms, each a **hard requirement** on its platform: `sandbox-exec` on macOS, `bwrap` (bubblewrap) on Linux. There is **no** unsandboxed mode, no `docker`/`firejail`, no config override, no `--sandbox`/`--yes` flag, no consent/feedback layer. If the platform's mechanism is unavailable (or the platform is neither macOS nor Linux), Ralph **aborts before any agent subprocess** with an actionable message.
110+
- **`sandbox/` package:** `models.py` (`SandboxMechanism`={`SANDBOX_EXEC`,`BWRAP`} only; `SandboxConfig`, `SandboxPermissions`, `resolve_paths`), `detect.py`, `exceptions.py`, `wrapper.py`, `adapters/` (`sandbox_exec.py`, `bwrap.py`, `__init__.py`). No `models` `SandboxResult`/`SandboxType`/`CandidateOutcome`/`SandboxDetectionReport`; no `feedback.py`; no `docker.py`/`firejail.py`. `models.py` imports the sibling leaf `exceptions` (acyclic, still no external deps / no import-time side effects) and does **fail-loud, filesystem-free `__post_init__` validation**: `SandboxPermissions` rejects a writable filesystem-root or bare `$HOME` (incl. `project_dir`) → `SandboxSetupError`; `SandboxConfig` rejects `project_dir != permissions.project_dir` (the bound `permissions` is stored as-is, no silent replace). `SandboxConfig.base_url` is **inert** (no adapter consumes it; never a confinement input).
111+
- **Detection:** one function `detect_sandbox(which_fn=shutil.which, system_fn=platform.system, *, run_fn=subprocess.run) -> SandboxMechanism`. macOS→`SANDBOX_EXEC` (binary-presence check), Linux→`BWRAP` (functional liveness probe `bwrap --ro-bind / / true`), else→raise `SandboxDetectionError`. No `detect_sandbox_with_report`, no `explicit=`, no config loader. `detect.py` does **not** import `mfbt.config` (cycle severed; `config.validate_config` has no sandbox block; no `_VALID_SANDBOX_TYPES`/`_CONFIG_OVERRIDE_KEY`).
112+
- **Adapters:** each is pure `build_prefix(config: SandboxConfig) -> list[str]` + `classify_exit(rc, sandbox_stderr) -> SandboxError|None`. `adapters/__init__.py` `get_adapter`/`get_classifier` are strict 2-entry dispatch (unknown mechanism → `SandboxError`, no identity/no-op). SBPL asset `commands/ralph/profiles/claude.sb` (MCLI-126 write-confine: `(allow default)` + global write-deny + per-writable re-allow + a **curated `/dev` device-node allow-list** — `/dev/null|zero|random|urandom|tty|dtracehelper|ptmx`, `^/dev/ttys[0-9]+$`, `(subpath "/dev/fd")` — **not** a blanket `(subpath "/dev")`; kept a single line so the comment-breakout test's balanced-sexpr invariant holds; network open) loaded via `importlib.resources`; hatchling auto-bundles non-`.py` package files. `classify_exit` re-attributes a non-zero exit to the sandbox **only** on the sandbox's own **line-anchored** stderr signature (`[Sandbox] ` wrapper prefix stripped first; sandbox_exec: `deny(\d+)` regex anywhere on a line OR a line starting `sandbox-exec:`; bwrap: a line starting `bwrap:`); else `None` = Claude's own exit, returned as `AgentResult`.
113+
- **`SandboxWrapper`:** `wrap_command(cmd)` canonical (`wrap(cmd)` alias); `prepare()` is a **no-arg no-op** lifecycle hook; `cleanup()` removes the sandbox-exec SBPL temp profile in `run()`'s `finally`. `mechanism=None`→`detect_sandbox()` opt-in (orchestrator passes a concrete mechanism; bad value→`SandboxError`). Sandbox stderr: pure `format_sandbox_stderr` (`[Sandbox] ` prefix) + `note_stderr_line` (always attributes) / `get_sandbox_stderr`; sandbox tool + `claude` share ONE stderr fd by design (attribution by `[Sandbox] ` marker, not OS-fd — do **not** split processes). `AgentResult.sandbox_stderr` defaulted.
114+
- **`AgentRunner`:** `__init__(*, project_dir=None, base_url=None)` (no `assume_yes`/`presented`). `sandbox_wrapper is None`→auto-detect once; injected wrapper used as-is. The no-wrapper default `base_url` is sourced from `mfbt.config.DEFAULT_CONFIG["base_url"]` (single source — no hand-synced `_DEFAULT_BASE_URL` constant). `build_command` **always** inserts `--dangerously-skip-permissions` at argv[1] (a real OS sandbox is always the boundary; headless `claude -p`/`stdin=DEVNULL` can't answer tool prompts). The subprocess `Popen` is launched with **`cwd=` pinned to the sandbox's own realpath'd `project_dir`** (single source = the wrapper's `SandboxConfig`; resolved exactly as the adapters resolve it) so the run location is always inside the confined/bound area, never the inherited parent CWD — `cwd` is part of the frozen Popen signature. After the timed `stderr` join, `run()` logs (WARNING) if the drain was truncated, and on a non-zero exit not attributed to a denial logs INFO (WARNING if truncated) so a possibly-missed denial is never fully silent. `run()` = a `try/except SandboxError` setup phase (build→`prepare()`→`wrap_command`) + the subprocess `try/…/finally cleanup()` phase; ordered `except` clauses (base `SandboxError` last) each `_emit_sandbox_error(exc); raise` (re-raise, **not** `SystemExit`). Popen `OSError` EPERM/EACCES→`SandboxDeniedError`. No `_handle_none_sandbox_fallback`/`acknowledge_unsandboxed`/`_emit_sandbox_note`/`_NONE_SANDBOX_WARNING`.
115+
- **Exceptions:** `SandboxError(message, *, mechanism=None)` (`__str__`=`"[<mechanism>] <message>"`); subclasses `SandboxDetectionError`, `SandboxSetupError`, `SandboxDeniedError`, `SandboxCompatibilityError` (reserved, defensively handled, never raised by current paths). All 5 re-exported from `sandbox/__init__`.
116+
- **Hard-fail wiring (TWO chokepoints — construction AND run):** (1) construction: `RalphOrchestrator.__init__` calls `detect_sandbox()`, logs+re-raises `SandboxDetectionError`. (2) post-construction: `orchestrator._implement_feature` has an `except SandboxError: logger.error(...); raise` **before** its broad `except Exception` so a `SandboxSetupError`/`SandboxDeniedError` from `agent.run()` is **never** downgraded to a per-feature `FAILED` (which would loop on a broken sandbox and mask a denial) — it propagates out of `run()`. CLI `commands/ralph/__init__.py`: `except SandboxDetectionError`→`display.error`+`typer.Exit(1)`; `display.sandbox_status(...)` before `orchestrator.run()`; `except SandboxError`→`display.error("sandbox failure — Ralph aborted: …")`+`typer.Exit(1)`; `summary.failed>0`→`typer.Exit(1)`. TUI `feature_list.py::_run_orchestrator`: `except SandboxDetectionError` (now `logger.error(exc_info=True)` — parity with CLI) wrapping the `RalphOrchestrator(...)` construction → notify(error) + `_return_to_browsing`; a separate `except SandboxError` around `run()` → notify("Sandbox failure — Ralph aborted: …", error) + `_return_to_browsing` (both fail closed visibly). `RalphConfig` has no `assume_yes`/`sandbox`; the pre-run `typer.confirm` prompt is gone; `--status` only conflicts with `--quiet`. `RalphHeader._sandbox_label()` always shows the mechanism (no UNSANDBOXED branch). `RalphDisplay`/`RalphTUIDisplay` keep `sandbox_status` (stdout/log, quiet-suppressed, enum `.value` verbatim); `sandbox_warning` removed.
117+
- **Debug:** module-scope `_debug_enabled = False` in `wrapper.py` AND `detect.py` (literal `# TODO: decision needed — wire to --<flag-name> and <ENV_VAR_NAME>`, intentionally unwired; tests `monkeypatch.setattr(<mod>, "_debug_enabled", True)`). `[sandbox-debug] ` via `wrapper.format_sandbox_debug`/`emit_debug_sandbox_stderr()`/`detect._probe_liveness`.
118+
- **Test gotchas:** `__init__`'s `logger` param shadows the module logger — use `logging.getLogger(__name__)`. `test_orchestrator.py` autouse fixture patches `orchestrator.detect_sandbox` (NOT `_with_report`) returning a bare `SandboxMechanism`. `test_agent.py` autouse fixture patches `agent.detect_sandbox` + `agent.SandboxWrapper`; `_stub_wrapper` defaults to `SANDBOX_EXEC` (a real sandbox) and sets `classify_exit.return_value=None`, so `build_command` includes `--dangerously-skip-permissions` at index 1 — assert presence, never absence; mock wrappers need `wrap_command.side_effect` (not `.wrap`). `test_detect.py::test_import_has_no_side_effects` must keep the parent-package `detect` attr save/restore in `finally` (full-suite-order hermeticity). Real-wrapper integration tests in `test_agent.py` exercise BWRAP/SANDBOX_EXEC through `run()` mocking only `Popen`/`which`.
109119
- **Key files:** `auth_flow.py` (shared OAuth), `coding_agents.py` (agent pre-flight checks), `tui/screens/phase_list.py`, `tui/screens/preflight_modal.py`, `tui/screens/feature_list.py` (unified browse + in-place Ralph panel), `commands/ralph/ralph_widgets.py`.
110120
- **TUI shortcuts:** `r` = Ralph, `ctrl+r` = Refresh, `d` = Describe, `enter` = Open/Detail, `esc` = Back, `?` = Help, `q` = Quit.

‎src/mfbt/commands/ralph/__init__.py‎

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -119,12 +119,6 @@ def ralph(
119119
"-q",
120120
help="Suppress live output; only show errors and final summary",
121121
),
122-
yes: bool = typer.Option(
123-
False,
124-
"--yes",
125-
"-y",
126-
help="Skip confirmation prompt and start immediately",
127-
),
128122
instructions: Optional[str] = typer.Option(
129123
None,
130124
"--instructions",
@@ -148,9 +142,9 @@ def ralph(
148142
)
149143
from mfbt.commands.ralph.types import AgentType, OrchestrationMode, PhaseInfo, RalphConfig
150144

151-
if status and (quiet or yes):
145+
if status and quiet:
152146
console.print(
153-
"[red]Error:[/red] --status is mutually exclusive with --quiet and --yes."
147+
"[red]Error:[/red] --status is mutually exclusive with --quiet."
154148
)
155149
raise typer.Exit(code=1)
156150

@@ -244,24 +238,44 @@ def ralph(
244238
if status:
245239
raise typer.Exit(code=0)
246240

247-
if not yes:
248-
typer.confirm("Continue?", abort=True)
249-
250241
from mfbt.commands.ralph.log_capture import RalphLogger
251242
from mfbt.config import get_mfbt_dir
252243

253244
log_base_dir = get_mfbt_dir() / "logs"
254245
ralph_logger = RalphLogger(log_base_dir, agent_type)
255246

256-
orchestrator = RalphOrchestrator(
257-
ralph_config, client, display, logger=ralph_logger
247+
from mfbt.commands.ralph.sandbox import (
248+
SandboxDetectionError,
249+
SandboxError,
258250
)
259251

252+
try:
253+
orchestrator = RalphOrchestrator(
254+
ralph_config, client, display, logger=ralph_logger
255+
)
256+
except SandboxDetectionError as exc:
257+
display.error(str(exc))
258+
raise typer.Exit(code=1) from exc
259+
260+
# Surface the active sandbox (quiet-suppressed) before the run
261+
# loop. Detection already hard-failed above if no sandbox is
262+
# available, so this is purely informational.
263+
if not quiet:
264+
display.sandbox_status(orchestrator.sandbox_mechanism.value)
265+
260266
try:
261267
summary = orchestrator.run()
262268
except AgentNotFoundError as exc:
263269
display.error(str(exc))
264270
raise typer.Exit(code=1) from exc
271+
except SandboxError as exc:
272+
# A post-construction sandbox failure (setup/denial) is a hard
273+
# abort, not a per-feature failure: the orchestrator re-raises
274+
# it out of run() rather than continuing the loop. Surface the
275+
# attributed message and exit non-zero, same as the
276+
# construction-time SandboxDetectionError above.
277+
display.error(f"sandbox failure — Ralph aborted: {exc}")
278+
raise typer.Exit(code=1) from exc
265279

266280
exit_code = 1 if summary.failed > 0 else 0
267281
raise typer.Exit(code=exit_code)

0 commit comments

Comments
 (0)