Skip to content

Commit f6232e8

Browse files
authored
Merge pull request #12 from penso/web-browsing
feat(tools): add web_search and web_fetch agent tools
2 parents 914fed9 + f8bf3dd commit f6232e8

21 files changed

Lines changed: 2695 additions & 64 deletions

CLAUDE.md

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,61 @@ biome check --write # Lint & format JavaScript files (installed via mise)
142142
When editing `Cargo.toml` or other TOML files, run `taplo fmt` to format them
143143
according to the project's `taplo.toml` configuration.
144144

145+
## Sandbox Architecture
146+
147+
The gateway runs user commands inside isolated containers (Docker or Apple
148+
Container). Key files:
149+
150+
- `crates/tools/src/sandbox.rs``Sandbox` trait, `DockerSandbox`,
151+
`AppleContainerSandbox`, `SandboxRouter`, image build/list/clean helpers
152+
- `crates/tools/src/exec.rs``ExecTool` that routes commands through the
153+
sandbox
154+
- `crates/cli/src/sandbox_commands.rs``moltis sandbox` CLI subcommands
155+
- `crates/config/src/schema.rs``SandboxConfig` with default packages list
156+
157+
### Pre-built images
158+
159+
Both backends support `build_image`: generate a Dockerfile with `FROM <base>`
160+
+ `RUN apt-get install ...`, then run `docker build` / `container build`.
161+
The image tag is a deterministic hash of the base image + sorted package
162+
list (`sandbox_image_tag`). The gateway pre-builds at startup; if the image
163+
already exists it's a no-op.
164+
165+
### Config-driven packages
166+
167+
Default packages are defined in `default_sandbox_packages()` in `schema.rs`.
168+
On first run (no config file), a `moltis.toml` is written with all defaults
169+
including the full packages list. Users edit that file to add/remove packages
170+
and restart — the image tag changes automatically, triggering a rebuild.
171+
172+
### Shared helpers
173+
174+
`sandbox_image_tag`, `sandbox_image_exists`, `list_sandbox_images`,
175+
`remove_sandbox_image`, `clean_sandbox_images` are module-level public
176+
functions in `sandbox.rs`, parameterised by CLI binary name. The
177+
`SandboxConfig::from(&config_schema::SandboxConfig)` impl converts the
178+
config-crate types to tools-crate types — use it instead of manual
179+
field-by-field conversion.
180+
181+
## Security
182+
183+
### WebSocket Origin validation (CSWSH protection)
184+
185+
The WebSocket upgrade handler in `server.rs` validates the `Origin` header.
186+
Cross-origin requests are rejected with 403. Loopback variants (`localhost`,
187+
`127.0.0.1`, `::1`) are treated as equivalent. Non-browser clients (no
188+
Origin header) are allowed through.
189+
190+
This prevents the attack class from GHSA-g8p2-7wf7-98mq where a malicious
191+
webpage could connect to the local gateway WebSocket from the victim's
192+
browser.
193+
194+
### SSRF protection
195+
196+
`web_fetch.rs` resolves DNS and checks the resulting IP against blocked
197+
ranges (loopback, private, link-local, CGNAT) before making HTTP requests.
198+
Any changes to web_fetch must preserve this check.
199+
145200
## CLI Auth Commands
146201

147202
The `auth` subcommand (`crates/cli/src/auth_commands.rs`) provides:
@@ -150,6 +205,15 @@ The `auth` subcommand (`crates/cli/src/auth_commands.rs`) provides:
150205
- `moltis auth reset-identity` — clear identity and user profile (triggers
151206
onboarding on next load)
152207

208+
## CLI Sandbox Commands
209+
210+
The `sandbox` subcommand (`crates/cli/src/sandbox_commands.rs`) provides:
211+
212+
- `moltis sandbox list` — list pre-built `moltis-sandbox:*` images
213+
- `moltis sandbox build` — build image from config (base + packages)
214+
- `moltis sandbox remove <tag>` — remove a specific image
215+
- `moltis sandbox clean` — remove all sandbox images
216+
153217
## Sensitive Data Handling
154218

155219
Never use plain `String` for passwords, API keys, tokens, or any secret
@@ -185,7 +249,6 @@ Rules:
185249
- **RwLock guards**: when a `RwLock<Option<Secret<String>>>` read guard is
186250
followed by a write in the same function, scope the read guard in a block
187251
`{ let guard = lock.read().await; ... }` to avoid deadlocks.
188-
189252
## Provider Implementation Guidelines
190253

191254
### Async all the way down

Cargo.lock

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,22 @@ multiple LLM providers and communication channels, inspired by
1919
management
2020
- **Memory and knowledge base** — embeddings-powered long-term memory
2121
- **Skills and plugins** — extensible skill system and plugin architecture
22+
- **Web browsing** — web search (Brave, Perplexity) and URL fetching with
23+
readability extraction and SSRF protection
2224
- **Scheduled tasks** — cron-based task execution
2325
- **OAuth flows** — built-in OAuth2 for provider authentication
2426
- **TLS support** — automatic self-signed certificate generation
2527
- **Observability** — OpenTelemetry tracing with OTLP export
28+
- **Sandboxed execution** — Docker and Apple Container backends with pre-built
29+
images, configurable packages, and per-session isolation
2630
- **Authentication** — password and passkey (WebAuthn) authentication with
2731
session cookies, API key support, and a first-run setup code flow
32+
- **WebSocket security** — Origin validation to prevent Cross-Site WebSocket
33+
Hijacking (CSWSH)
2834
- **Onboarding wizard** — guided setup for agent identity (name, emoji,
2935
creature, vibe, soul) and user profile
36+
- **Default config on first run** — writes a complete `moltis.toml` with all
37+
defaults so you can edit packages and settings without recompiling
3038
- **Configurable directories**`--config-dir` / `--data-dir` CLI flags and
3139
`MOLTIS_CONFIG_DIR` / `MOLTIS_DATA_DIR` environment variables
3240

@@ -60,6 +68,20 @@ cargo run -- gateway --config-dir /path/to/config --data-dir /path/to/data
6068
cargo test --all-features
6169
```
6270

71+
### Sandbox Image Management
72+
73+
```bash
74+
moltis sandbox list # List pre-built sandbox images
75+
moltis sandbox build # Build image from configured base + packages
76+
moltis sandbox clean # Remove all pre-built sandbox images
77+
moltis sandbox remove <tag> # Remove a specific image
78+
```
79+
80+
The gateway pre-builds a sandbox image at startup from the base image
81+
(`ubuntu:25.10`) plus the packages listed in `moltis.toml`. Edit the
82+
`[tools.exec.sandbox] packages` list and restart — a new image with a
83+
different tag is built automatically.
84+
6385
## Project Structure
6486

6587
Moltis is organized as a Cargo workspace with the following crates:

crates/cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ moltis-gateway = { workspace = true }
3737
moltis-oauth = { workspace = true }
3838
moltis-onboarding = { workspace = true }
3939
moltis-skills = { workspace = true }
40+
moltis-tools = { workspace = true }
4041
open = { workspace = true }
4142
reqwest = { workspace = true }
4243
tokio = { workspace = true }

crates/cli/src/main.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
mod auth_commands;
2+
mod sandbox_commands;
23

34
use {
45
clap::{Parser, Subcommand},
@@ -82,6 +83,11 @@ enum Commands {
8283
#[command(subcommand)]
8384
action: SkillAction,
8485
},
86+
/// Sandbox image management.
87+
Sandbox {
88+
#[command(subcommand)]
89+
action: sandbox_commands::SandboxAction,
90+
},
8591
/// Install the Moltis CA certificate into the system trust store.
8692
#[cfg(feature = "tls")]
8793
TrustCa,
@@ -266,6 +272,7 @@ async fn main() -> anyhow::Result<()> {
266272
},
267273
Commands::Onboard => moltis_onboarding::wizard::run_onboarding().await,
268274
Commands::Auth { action } => auth_commands::handle_auth(action).await,
275+
Commands::Sandbox { action } => sandbox_commands::handle_sandbox(action).await,
269276
Commands::Skills { action } => handle_skills(action).await,
270277
#[cfg(feature = "tls")]
271278
Commands::TrustCa => trust_ca().await,

crates/cli/src/sandbox_commands.rs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
use {anyhow::Result, clap::Subcommand};
2+
3+
use moltis_tools::sandbox;
4+
5+
#[derive(Subcommand)]
6+
pub enum SandboxAction {
7+
/// List pre-built sandbox images.
8+
List,
9+
/// Build a sandbox image from the configured base + packages.
10+
Build,
11+
/// Remove a specific sandbox image by tag.
12+
Remove {
13+
/// Image tag (e.g. moltis-sandbox:abc123).
14+
tag: String,
15+
},
16+
/// Remove all pre-built sandbox images.
17+
Clean,
18+
}
19+
20+
pub async fn handle_sandbox(action: SandboxAction) -> Result<()> {
21+
match action {
22+
SandboxAction::List => list().await,
23+
SandboxAction::Build => build().await,
24+
SandboxAction::Remove { tag } => remove(&tag).await,
25+
SandboxAction::Clean => clean().await,
26+
}
27+
}
28+
29+
async fn list() -> Result<()> {
30+
let images = sandbox::list_sandbox_images().await?;
31+
if images.is_empty() {
32+
println!("No sandbox images found.");
33+
return Ok(());
34+
}
35+
println!("{:<45} {:>10} CREATED", "TAG", "SIZE");
36+
for img in &images {
37+
println!("{:<45} {:>10} {}", img.tag, img.size, img.created);
38+
}
39+
Ok(())
40+
}
41+
42+
async fn build() -> Result<()> {
43+
let config = moltis_config::discover_and_load();
44+
let sandbox_config = sandbox::SandboxConfig::from(&config.tools.exec.sandbox);
45+
46+
let packages = sandbox_config.packages.clone();
47+
if packages.is_empty() {
48+
println!("No packages configured — nothing to build.");
49+
println!("Add packages to [tools.exec.sandbox] in your config file.");
50+
return Ok(());
51+
}
52+
53+
let base = sandbox_config
54+
.image
55+
.clone()
56+
.unwrap_or_else(|| sandbox::DEFAULT_SANDBOX_IMAGE.to_string());
57+
let tag = sandbox::sandbox_image_tag(&base, &packages);
58+
println!("Base: {base}");
59+
println!("Packages: {}", packages.join(", "));
60+
println!("Tag: {tag}");
61+
println!();
62+
63+
// Force mode to All so create_sandbox returns a real backend.
64+
let sandbox_config = sandbox::SandboxConfig {
65+
mode: sandbox::SandboxMode::All,
66+
..sandbox_config
67+
};
68+
let backend = sandbox::create_sandbox(sandbox_config);
69+
match backend.build_image(&base, &packages).await? {
70+
Some(result) => {
71+
if result.built {
72+
println!("Image built successfully: {}", result.tag);
73+
} else {
74+
println!("Image already exists: {}", result.tag);
75+
}
76+
},
77+
None => {
78+
println!(
79+
"Backend '{}' does not support image building.",
80+
backend.backend_name()
81+
);
82+
},
83+
}
84+
Ok(())
85+
}
86+
87+
async fn remove(tag: &str) -> Result<()> {
88+
sandbox::remove_sandbox_image(tag).await?;
89+
println!("Removed: {tag}");
90+
Ok(())
91+
}
92+
93+
async fn clean() -> Result<()> {
94+
let count = sandbox::clean_sandbox_images().await?;
95+
if count == 0 {
96+
println!("No sandbox images to remove.");
97+
} else {
98+
println!(
99+
"Removed {count} sandbox image{}.",
100+
if count == 1 {
101+
""
102+
} else {
103+
"s"
104+
}
105+
);
106+
}
107+
Ok(())
108+
}

crates/config/src/loader.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,12 @@ pub fn discover_and_load() -> MoltisConfig {
6363
},
6464
}
6565
} else {
66-
debug!("no config file found, using defaults");
66+
debug!("no config file found, writing default config");
67+
let config = MoltisConfig::default();
68+
if let Err(e) = write_default_config(&config) {
69+
warn!(error = %e, "failed to write default config file");
70+
}
71+
return config;
6772
}
6873
MoltisConfig::default()
6974
}
@@ -170,6 +175,23 @@ fn save_config_inner(config: &MoltisConfig) -> anyhow::Result<PathBuf> {
170175
Ok(path)
171176
}
172177

178+
/// Write the default config file to the user-global config path.
179+
/// Only called when no config file exists yet.
180+
fn write_default_config(config: &MoltisConfig) -> anyhow::Result<()> {
181+
let path = find_or_default_config_path();
182+
if path.exists() {
183+
return Ok(());
184+
}
185+
if let Some(parent) = path.parent() {
186+
std::fs::create_dir_all(parent)?;
187+
}
188+
let toml_str =
189+
toml::to_string_pretty(config).map_err(|e| anyhow::anyhow!("serialize config: {e}"))?;
190+
std::fs::write(&path, &toml_str)?;
191+
debug!(path = %path.display(), "wrote default config file");
192+
Ok(())
193+
}
194+
173195
fn parse_config(raw: &str, path: &Path) -> anyhow::Result<MoltisConfig> {
174196
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("toml");
175197

0 commit comments

Comments
 (0)