Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,9 @@ members = [
"crates/paddleboard_credentials_provider",
"crates/paddleboard_env_vars",
"crates/paddleboard_sandbox_prereqs",
"crates/paddleboard_sandbox_prereqs_state",
"crates/paddleboard_sandbox_prereqs_ui",
"crates/paddleboard_sandbox_settings",
"crates/zeta_prompt",
"crates/zlog",
"crates/zlog_settings",
Expand Down Expand Up @@ -480,7 +482,9 @@ paddleboard_actions = { path = "crates/paddleboard_actions" }
paddleboard_credentials_provider = { path = "crates/paddleboard_credentials_provider" }
paddleboard_env_vars = { path = "crates/paddleboard_env_vars" }
paddleboard_sandbox_prereqs = { path = "crates/paddleboard_sandbox_prereqs" }
paddleboard_sandbox_prereqs_state = { path = "crates/paddleboard_sandbox_prereqs_state" }
paddleboard_sandbox_prereqs_ui = { path = "crates/paddleboard_sandbox_prereqs_ui" }
paddleboard_sandbox_settings = { path = "crates/paddleboard_sandbox_settings" }
edit_prediction = { path = "crates/edit_prediction" }
zeta_prompt = { path = "crates/zeta_prompt" }
zlog = { path = "crates/zlog" }
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ An editor for AI-driven development. PaddleBoard is a fork of [Zed](https://gith

Every Zed feature still works. The additions, all reachable from the command palette or panel bar:

- **Secure agent sandbox.** Tool calls that run code execute inside an ephemeral `ubuntu:latest` container via [Podman](https://podman.io/) + the [`runsc`](https://gvisor.dev/) (gVisor) kernel runtime. Your project is bind-mounted; the host filesystem is not exposed. Permissions still flow through Zed's existing approve / deny / always-allow UI.
- **Secure agent sandbox.** Tool calls that run code execute inside an ephemeral `ubuntu:latest` container via [Podman](https://podman.io/) + the [`runsc`](https://gvisor.dev/) (gVisor) kernel runtime. Your project is bind-mounted; the host filesystem is not exposed. Permissions still flow through Zed's existing approve / deny / always-allow UI. A status-bar shield surfaces the live prereq status, and missing prereqs are enforced — `paddleboard_sandbox.on_missing_runtime` chooses between `block` (default, opens the install modal), `fall_back_to_host`, or `warn_once`.
- **Forwarded ports.** Long-lived services (dev servers, `adk web`, demo apps) run in detached gVisor containers. PaddleBoard publishes the port on `127.0.0.1` only and shows a one-click link above the browser viewport. Stop the service with the × on the row — the container is discarded.
- **Embedded browser panel.** Native Chromium/WebKit panel that docks like any other. Used for forwarded service URLs and for the **Unsloth Studio** one-step launch (`workspace: Open Unsloth` spins up a Jupyter fine-tuning environment and navigates straight to it).
- **Sandboxed MCP servers.** A `sandboxed_stdio` context-server transport runs MCP servers inside Podman + gVisor instead of on your host. Stdin/stdout are proxied transparently so the JSON-RPC framing is unchanged. Opt-in per server; the original `stdio` transport still works for servers that don't need isolation.
Expand Down
14 changes: 14 additions & 0 deletions RECAPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ Running log of completed work sessions, newest first. Each entry summarizes a co

## 2026-05-14

### Sandbox prerequisites enforcement (PR-C: gate three tool sites)
- Closed the loop on the sandbox-prereqs feature: PR-A (data) and PR-B (UI) were visibility only; this is the enforcement half that actually refuses to run podman when prereqs are missing.
- Two new crates: `paddleboard_sandbox_prereqs_state` extracts the `SandboxPrereqs` GPUI global out of the UI crate so non-UI callers (`agent`, `project`) can read the cached probe status without a `workspace` cycle. `paddleboard_sandbox_settings` owns the policy model: `OnMissingRuntime` enum (Block / FallBackToHost / WarnOnce), `SandboxSettings` struct (with `RegisterSetting` derive), a pure `decide_gate(prereqs, settings) -> SandboxGateDecision`, and a `claim_warn_once_slot()` AtomicBool so `warn_once` is genuinely once-per-session. 7 unit tests covering the decision matrix.
- Settings wiring: new tagged file `crates/settings_content/src/paddleboard_sandbox.rs` defines `PaddleboardSandboxContent` + `PaddleboardOnMissingRuntimeContent`; three tagged additions to `settings_content.rs` add the module + re-export + field. Defaults wired into `assets/settings/default.json` with a `// PaddleBoard:` comment block. Settings registration triggered via a force-link `init(cx)` from `paddleboard/src/main.rs`.
- Three gate sites:
- `crates/agent/src/tools/sandbox_tool.rs` — gate in `cx.update` block; `Block` returns clear error pointing at the status bar; `FallBackToHost` strips the podman wrapper and runs `bash -c <user_command>` in the working directory; `WarnOnce` logs once + proceeds sandboxed.
- `crates/agent/src/tools/sandbox_service_tool.rs` — same gate; for `FallBackToHost` the service spawns on host with `host_port == container_port`, still registers a Forwarded Ports entry so the user gets a clickable link.
- `crates/project/src/context_server_store.rs` — gated at the `ContextServerConfiguration::Sandboxed` arm (caller of `ContextServer::sandboxed_stdio`), keeping the upstream-shaped `sandboxed_stdio_transport.rs` untouched per fork hygiene. `FallBackToHost` falls through to plain `ContextServer::stdio`.
- Fork-hygiene cost: 1 field in `SettingsContent`, 1 mod decl + 1 use re-export in `settings_content.rs`, 1 field in `VsCodeImporter::settings_content()`, all tagged `// PaddleBoard:`. Everything else lives in PaddleBoard crates.
- Verified: full `cargo build -p paddleboard` clean; `cargo clippy --no-deps` over the changed crates clean; all unit tests pass (7 in settings crate, 3 in sandbox_tool, 7 in sandbox_service_tool, 6 in sandboxed_stdio_transport).
- Docs: `WELCOME.md` Sandbox section expanded with the policy values and the shield-icon UX. `crates/workspace/src/tour.md` section 2 mirrors that. `README.md`'s "Secure agent sandbox" bullet under `## What's different from Zed` got the same shield-icon + `on_missing_runtime` note tacked on. Existing users won't see the tour update until the materialization gate at `workspace.rs:785` / `paddleboard/src/main.rs:1494` is loosened — flagged for a future session, not in this PR.
- Workflow rule extended: the WELCOME/tour feedback memory now also covers `README.md`. User asked for README to be kept in sync on the same turn as WELCOME + tour after I'd updated the latter two but left README stale on this PR. See `feedback-update-welcome-and-tour`.
- Follow-ups: nothing committed yet on `feat/sandbox-prereqs-enforcement` — the user will decide on commit + PR after review. The duplicate "Sandboxed MCP Servers" block at the bottom of `tour.md` (a pre-existing bug) was intentionally left alone to keep this PR scoped.

### Sandbox prerequisites UI (PR-B: visibility unit)
- New crate `paddleboard_sandbox_prereqs_ui` (~350 lines) layered on PR-A's data layer. Three pieces in one file: `SandboxPrereqs` (a `gpui::Global` holding the latest `SandboxStatus` + a `refreshing` flag), `SandboxStatusItem` (status-bar entry — colored shield icon), `SandboxPrereqsModal` (full install-guidance UI with per-step Copy buttons + Refresh).
- Async-to-GPUI bridge uses `gpui_tokio::Tokio::spawn(cx, async { check().await })`. The probe runs on tokio's pool (needed for `tokio::process::Command`); the result is written back to the global via `cx.update_global` on the foreground thread, which automatically notifies observers so the status bar + modal re-render.
Expand Down
8 changes: 8 additions & 0 deletions WELCOME.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ When the agent needs to run untrusted code, compile binaries, or execute tests,

This means an agent mistake cannot touch anything outside the container. Once the command finishes, the container is discarded.

**Prerequisites are enforced.** PaddleBoard probes for Podman and gVisor on startup; the result lives in the status-bar shield icon (green = ready, yellow = degraded, red = missing). When prereqs are missing the agent can't silently fall through to a `podman: command not found` shell error — the policy in `paddleboard_sandbox.on_missing_runtime` decides:

- `"block"` (default) — refuse to launch and surface the install modal. The agent gets a clear "sandbox prerequisites missing" error.
- `"fall_back_to_host"` — run the command on the host without a container. Escape hatch for Windows or environments where the sandbox stack is genuinely unavailable.
- `"warn_once"` — emit a one-shot toast with install guidance, then proceed sandboxed.

Click the shield icon any time to see the live status and copy-paste install commands for your OS.

---

### Forwarded ports — sandbox services in the browser
Expand Down
21 changes: 21 additions & 0 deletions assets/settings/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -2578,4 +2578,25 @@
//
// Example: {"log": {"client": "warn"}}
"log": {},

// PaddleBoard: configuration for the sandbox enforcement layer.
// Controls what happens when a sandboxed tool (one-shot exec, long-lived
// service, or sandboxed MCP transport) tries to launch but the host
// prerequisites (Podman, gVisor `runsc`) are not satisfied.
"paddleboard_sandbox": {
// Policy applied when prereqs are missing at tool-launch time.
//
// - "block" (default): refuse to launch and surface the install modal;
// the agent receives a clear error so it doesn't think the tool hung.
// - "fall_back_to_host": run the command directly on the host without
// sandboxing. Escape hatch for environments where the sandbox stack is
// unavailable (Windows) or the user has accepted the risk.
// - "warn_once": proceed sandboxed but emit a one-shot notification with
// install guidance.
"on_missing_runtime": "block",
// Whether PaddleBoard probes the host for sandbox prerequisites at all.
// When false, the gate always allows tools to proceed regardless of
// cached state.
"prereq_check_enabled": true
},
}
2 changes: 2 additions & 0 deletions crates/agent/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ uuid.workspace = true
watch.workspace = true
web_search.workspace = true
paddleboard_env_vars.workspace = true
paddleboard_sandbox_prereqs_state.workspace = true
paddleboard_sandbox_settings.workspace = true
zstd.workspace = true

[dev-dependencies]
Expand Down
83 changes: 81 additions & 2 deletions crates/agent/src/tools/sandbox_service_tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ use agent_settings::AgentSettings;
use anyhow::Result;
use browser::{ForwardedPort, ForwardedPorts};
use gpui::{App, AppContext as _, Entity, SharedString, Task};
use paddleboard_sandbox_prereqs_state::SandboxPrereqs;
use paddleboard_sandbox_settings::{SandboxGateDecision, SandboxSettings, decide_gate};
use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -101,7 +103,7 @@ impl AgentTool for SandboxServiceTool {
.await
.map_err(|e| format!("Failed to receive tool input: {e}"))?;

let (working_dir, authorize) = cx.update(|cx| {
let (working_dir, authorize, gate) = cx.update(|cx| {
let working_dir = resolve_worktree_dir(&input.cd, &self.project, cx)
.map_err(|err| err.to_string())?;

Expand All @@ -126,13 +128,47 @@ impl AgentTool for SandboxServiceTool {
))
}
};
Ok::<_, String>((working_dir, authorize))

let gate = decide_gate(
SandboxPrereqs::status(cx),
SandboxSettings::get_global(cx),
);

Ok::<_, String>((working_dir, authorize, gate))
})?;

if let Some(authorize) = authorize {
authorize.await.map_err(|e| e.to_string())?;
}

let run_on_host = match &gate {
SandboxGateDecision::Block { reason } => {
return Err(format!(
"Sandbox prerequisites missing: {reason}. \
Open Sandbox Prerequisites from the status bar to install Podman / gVisor, \
or set `paddleboard_sandbox.on_missing_runtime` to \"fall_back_to_host\" \
to run on the host without a container."
));
}
SandboxGateDecision::WarnOnce { reason } => {
if paddleboard_sandbox_settings::claim_warn_once_slot() {
log::warn!(
"PaddleBoard sandbox: {reason}. Starting service sandboxed; \
open Sandbox Prerequisites to install."
);
}
false
}
SandboxGateDecision::FallBackToHost { reason } => {
log::warn!(
"PaddleBoard sandbox: {reason}. Starting service on the host \
(no container) per `paddleboard_sandbox.on_missing_runtime`."
);
true
}
SandboxGateDecision::Allow => false,
};

let image = input
.image
.clone()
Expand All @@ -157,6 +193,49 @@ impl AgentTool for SandboxServiceTool {

let env_args = resolve_forward_env(input.forward_env.as_deref());

// Host fallback bypasses podman entirely: we spawn the service in
// the user's host shell. Port mapping is lost — the service binds
// directly to its declared port on localhost, so `host_port ==
// container_port`. We still register a Forwarded Ports entry so
// the user gets a clickable link.
if run_on_host {
let host_command_id = format!("host-{}", std::process::id());
let host_wd_for_spawn = working_dir.clone();
let user_command = input.command.clone();
let env_args_host = env_args.clone();
cx.background_spawn(async move {
let mut cmd = new_command("bash");
cmd.current_dir(&host_wd_for_spawn);
for env_arg in &env_args_host {
if let Some((name, value)) = env_arg.split_once('=') {
cmd.env(name, value);
}
}
cmd.args(["-c", &user_command]);
if let Err(error) = cmd.spawn() {
log::warn!(
"sandbox_service_tool: host fallback spawn failed: {error}"
);
}
})
.detach();

let port = ForwardedPort {
label: SharedString::from(label.clone()),
host_port: container_port,
container_id: Arc::from(host_command_id.as_str()),
};
cx.update(|cx| ForwardedPorts::register(cx, port));

let quoted = shell_single_quote(&input.command);
return Ok(format!(
"Started service `{quoted}` on the host (sandbox prerequisites missing; \
running without a container per `paddleboard_sandbox.on_missing_runtime`).\n\
Listening on port {container_port}.\n\
Available at http://localhost:{container_port} (registered in the browser panel as `{label}`)."
));
}

let container_id = cx
.background_spawn({
let image = image.clone();
Expand Down
Loading