P6: browser.goto — real Chromium browsing with live web taint

- SandboxSpec gains an egress flag (default false — the kernel suite
  still proves zero-network for agent sandboxes). Egress-enabled
  containers exist ONLY for the browser: no credentials, no broker
  route, bridge network with host-gateway alias for local test pages
- images/agent-browser: Alpine Chromium, uid 10001, setuid bits
  stripped — same non-root hardening as agent-base
- browser.goto tool: headless chromium --dump-dom in the agent's
  browser container; HTML stripped to readable text (4k cap) and
  returned with output_taint=web; viewport screenshot captured,
  base64'd out of the container, stored in the blob store
- Taint semantics tightened: the step that PRODUCED untrusted output
  now carries its own taint (recorded before the step row), not just
  later steps — chat.inbox test updated to the stricter §15 reading
- GET /api/claws/{id}/browser/viewport.png serves the latest capture;
  BrowserApp polls it and renders the live viewport (spec §7.1),
  keeping the empty state until the agent has browsed
- Proven end to end with REAL Chromium against a REAL local page:
  content 'Revenue up 14 percent' returned tainted web; the gated
  email.send that follows carries 'web' in its approval taint_sources
  (untrusted content can never quietly reach outward); screenshot
  verified by PNG magic bytes

152 Rust tests + 63 frontend + 27 Playwright journeys.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-10 09:41:40 -05:00
co-authored by Claude Fable 5
parent 7392ce1d08
commit 4f253bec93
20 changed files with 531 additions and 28 deletions
+18 -7
View File
@@ -85,19 +85,29 @@ async fn run() -> Result<(), String> {
}; };
// Environment tools need a container engine; absence is tolerated // Environment tools need a container engine; absence is tolerated
// (shell.exec reports it per-call) so the API still serves. // (shell.exec reports it per-call) so the API still serves.
let sandboxes = if config.sandbox.enabled { let (sandboxes, browser) = if config.sandbox.enabled {
match tc_sandbox::DockerDriver::connect() { match tc_sandbox::DockerDriver::connect() {
Ok(driver) => Some(std::sync::Arc::new(tc_runtime::SandboxManager::new( Ok(driver) => {
std::sync::Arc::new(driver), let driver: std::sync::Arc<dyn tc_sandbox::SandboxDriver> =
&config.sandbox.image, std::sync::Arc::new(driver);
))), (
Some(std::sync::Arc::new(tc_runtime::SandboxManager::new(
driver.clone(),
&config.sandbox.image,
))),
Some(std::sync::Arc::new(
tc_runtime::SandboxManager::new(driver, &config.sandbox.browser_image)
.with_egress(),
)),
)
}
Err(error) => { Err(error) => {
eprintln!("teamclaw-server: sandbox engine unavailable: {error}"); eprintln!("teamclaw-server: sandbox engine unavailable: {error}");
None (None, None)
} }
} }
} else { } else {
None (None, None)
}; };
let runtime = Runtime::with_blob_store( let runtime = Runtime::with_blob_store(
pool.clone(), pool.clone(),
@@ -108,6 +118,7 @@ async fn run() -> Result<(), String> {
broker_socket: Some(PathBuf::from(&config.broker.socket_path)), broker_socket: Some(PathBuf::from(&config.broker.socket_path)),
slack_base_url: config.slack.base_url.clone(), slack_base_url: config.slack.base_url.clone(),
sandboxes, sandboxes,
browser,
}, },
blob, blob,
); );
+4
View File
@@ -77,6 +77,10 @@ pub fn router(state: AppState) -> Router {
.route("/api/openclaw/files", get(routes::files::openclaw_files)) .route("/api/openclaw/files", get(routes::files::openclaw_files))
.route("/api/shared-drive/files", get(routes::files::shared_files)) .route("/api/shared-drive/files", get(routes::files::shared_files))
.route("/api/slack/events", post(routes::slack::events)) .route("/api/slack/events", post(routes::slack::events))
.route(
"/api/claws/{clawId}/browser/viewport.png",
get(routes::browser::viewport),
)
.route("/api/apps", get(routes::apps::directory)) .route("/api/apps", get(routes::apps::directory))
.route("/api/apps/connect", post(routes::apps::connect)) .route("/api/apps/connect", post(routes::apps::connect))
.route("/api/apps/oauth/start", post(routes::oauth::start)) .route("/api/apps/oauth/start", post(routes::oauth::start))
+24
View File
@@ -0,0 +1,24 @@
//! The Browser app's viewport (§7.1): the latest screenshot captured by
//! browser.goto, straight from the blob store.
use axum::extract::{Path, State};
use axum::http::{header, StatusCode};
use axum::response::IntoResponse;
use tc_domain::AgentId;
use crate::routes::claws::workspace_agent;
use crate::{ApiError, AppState, Authed};
/// GET /api/claws/{clawId}/browser/viewport.png
pub async fn viewport(
State(state): State<AppState>,
Authed(user): Authed,
Path(claw_id): Path<AgentId>,
) -> Result<impl IntoResponse, ApiError> {
let agent = workspace_agent(&state, &user, claw_id).await?;
let key = format!("{}/browser/{}/viewport.png", user.workspace_id, agent.id);
match state.runtime.blob().get(&key).await {
Ok(png) => Ok(([(header::CONTENT_TYPE, "image/png")], png).into_response()),
Err(_) => Ok(StatusCode::NOT_FOUND.into_response()),
}
}
+1
View File
@@ -2,6 +2,7 @@ pub mod approvals;
pub mod apps; pub mod apps;
pub mod auth; pub mod auth;
pub mod billing; pub mod billing;
pub mod browser;
pub mod claw_chat; pub mod claw_chat;
pub mod claws; pub mod claws;
pub mod files; pub mod files;
+1
View File
@@ -66,6 +66,7 @@ async fn mention_round_trip_verifies_runs_and_gates_the_reply() {
model: "scripted".into(), model: "scripted".into(),
max_tokens: 1024, max_tokens: 1024,
sandboxes: None, sandboxes: None,
browser: None,
broker_socket: Some(socket.clone()), broker_socket: Some(socket.clone()),
slack_base_url: "http://127.0.0.1:1".into(), // never reached here slack_base_url: "http://127.0.0.1:1".into(), // never reached here
}, },
+3
View File
@@ -136,6 +136,8 @@ impl Default for SlackConfig {
pub struct SandboxConfig { pub struct SandboxConfig {
/// Agent sandbox image (must exist locally / be preloaded in cluster). /// Agent sandbox image (must exist locally / be preloaded in cluster).
pub image: String, pub image: String,
/// Chromium image for browser.goto (the only egress-enabled sandbox).
pub browser_image: String,
/// Disable to run without environment tools (shell.exec errors). /// Disable to run without environment tools (shell.exec errors).
pub enabled: bool, pub enabled: bool,
} }
@@ -144,6 +146,7 @@ impl Default for SandboxConfig {
fn default() -> Self { fn default() -> Self {
SandboxConfig { SandboxConfig {
image: "teamclaw/agent-base:dev".into(), image: "teamclaw/agent-base:dev".into(),
browser_image: "teamclaw/agent-browser:dev".into(),
enabled: true, enabled: true,
} }
} }
+24 -6
View File
@@ -36,9 +36,16 @@ pub struct RuntimeConfig {
pub slack_base_url: String, pub slack_base_url: String,
/// Sandbox runtime for shell.exec; None disables environment tools. /// Sandbox runtime for shell.exec; None disables environment tools.
pub sandboxes: Option<std::sync::Arc<crate::SandboxManager>>, pub sandboxes: Option<std::sync::Arc<crate::SandboxManager>>,
/// Egress-enabled browser containers for browser.goto.
pub browser: Option<std::sync::Arc<crate::SandboxManager>>,
} }
impl RuntimeConfig { impl RuntimeConfig {
pub fn with_browser(mut self, browser: std::sync::Arc<crate::SandboxManager>) -> RuntimeConfig {
self.browser = Some(browser);
self
}
pub fn with_sandboxes( pub fn with_sandboxes(
mut self, mut self,
sandboxes: std::sync::Arc<crate::SandboxManager>, sandboxes: std::sync::Arc<crate::SandboxManager>,
@@ -55,6 +62,7 @@ impl RuntimeConfig {
broker_socket: None, broker_socket: None,
slack_base_url: "https://slack.com/api".into(), slack_base_url: "https://slack.com/api".into(),
sandboxes: None, sandboxes: None,
browser: None,
} }
} }
} }
@@ -162,6 +170,10 @@ impl Runtime {
} }
} }
pub fn blob(&self) -> Arc<dyn BlobStore> {
self.inner.blob.clone()
}
pub fn pool(&self) -> &PgPool { pub fn pool(&self) -> &PgPool {
&self.inner.pool &self.inner.pool
} }
@@ -381,6 +393,7 @@ impl Runtime {
broker_socket: self.inner.config.broker_socket.clone(), broker_socket: self.inner.config.broker_socket.clone(),
slack_base_url: self.inner.config.slack_base_url.clone(), slack_base_url: self.inner.config.slack_base_url.clone(),
sandboxes: self.inner.config.sandboxes.clone(), sandboxes: self.inner.config.sandboxes.clone(),
browser: self.inner.config.browser.clone(),
}; };
state.step_seq += 1; state.step_seq += 1;
@@ -452,6 +465,7 @@ impl Runtime {
broker_socket: self.inner.config.broker_socket.clone(), broker_socket: self.inner.config.broker_socket.clone(),
slack_base_url: self.inner.config.slack_base_url.clone(), slack_base_url: self.inner.config.slack_base_url.clone(),
sandboxes: self.inner.config.sandboxes.clone(), sandboxes: self.inner.config.sandboxes.clone(),
browser: self.inner.config.browser.clone(),
}; };
loop { loop {
@@ -525,15 +539,19 @@ impl Runtime {
Ok(value) => (StepStatus::Ok, value), Ok(value) => (StepStatus::Ok, value),
Err(message) => (StepStatus::Error, json!({"error": message})), Err(message) => (StepStatus::Error, json!({"error": message})),
}; };
// Taint lands BEFORE the step record: the step that
// produced untrusted output carries its own taint (§15).
if status == StepStatus::Ok {
if let Some(source) = self.inner.tools.output_taint_of(&tool.name) {
let tag = source.as_str().to_owned();
if !state.taint.contains(&tag) {
state.taint.push(tag);
}
}
}
self.record_step(&state, &tool, status, &output).await?; self.record_step(&state, &tool, status, &output).await?;
self.emit_step_finished(run_id, &mut state, status, &output) self.emit_step_finished(run_id, &mut state, status, &output)
.await?; .await?;
if let Some(source) = self.inner.tools.output_taint_of(&tool.name) {
let tag = source.as_str().to_owned();
if !state.taint.contains(&tag) {
state.taint.push(tag);
}
}
state.assistant_parts.push(ContentPart::ToolUse { state.assistant_parts.push(ContentPart::ToolUse {
id: tool.id.clone(), id: tool.id.clone(),
name: tool.name.clone(), name: tool.name.clone(),
+10
View File
@@ -20,6 +20,7 @@ impl std::fmt::Debug for SandboxManager {
pub struct SandboxManager { pub struct SandboxManager {
driver: Arc<dyn SandboxDriver>, driver: Arc<dyn SandboxDriver>,
image: String, image: String,
egress: bool,
handles: Mutex<HashMap<AgentId, SandboxHandle>>, handles: Mutex<HashMap<AgentId, SandboxHandle>>,
} }
@@ -28,10 +29,18 @@ impl SandboxManager {
SandboxManager { SandboxManager {
driver, driver,
image: image.to_owned(), image: image.to_owned(),
egress: false,
handles: Mutex::new(HashMap::new()), handles: Mutex::new(HashMap::new()),
} }
} }
/// The browser-container variant: egress on, no credentials inside,
/// all output tainted `web` by the calling tool.
pub fn with_egress(mut self) -> SandboxManager {
self.egress = true;
self
}
/// Runs `sh -lc <command>` in the agent's sandbox, provisioning it on /// Runs `sh -lc <command>` in the agent's sandbox, provisioning it on
/// first use. The sandbox has no egress and no credentials — running /// first use. The sandbox has no egress and no credentials — running
/// agent-authored code here is the point of the architecture. /// agent-authored code here is the point of the architecture.
@@ -49,6 +58,7 @@ impl SandboxManager {
memory_bytes: 512 * 1024 * 1024, memory_bytes: 512 * 1024 * 1024,
nano_cpus: 1_000_000_000, nano_cpus: 1_000_000_000,
pids_limit: 256, pids_limit: 256,
egress: self.egress,
}; };
let handle = self let handle = self
.driver .driver
+162
View File
@@ -0,0 +1,162 @@
//! browser.goto — real headless Chromium in the agent's BROWSER container
//! (the only egress-enabled sandbox; no credentials, no broker route).
//! Page text returns tainted `web`: §15 treats it as data, never
//! instructions, and any later externally-reaching call gates on it.
use serde_json::json;
use tc_tools::{Effect, TaintSource};
use super::{Tool, ToolContext, ToolDescriptor};
const PAGE_TEXT_LIMIT: usize = 4000;
/// Strips tags and collapses whitespace — enough for the model to read
/// the page without shipping raw HTML into the prompt.
fn html_to_text(html: &str) -> String {
let mut text = String::with_capacity(html.len() / 4);
let mut in_tag = false;
let mut in_script = false;
let lower = html.to_lowercase();
let mut i = 0;
for (idx, c) in html.char_indices() {
i = idx;
if in_script {
if lower[idx..].starts_with("</script") || lower[idx..].starts_with("</style") {
in_script = false;
in_tag = true;
}
continue;
}
match c {
'<' => {
in_tag = true;
if lower[idx..].starts_with("<script") || lower[idx..].starts_with("<style") {
in_script = true;
}
if !text.ends_with(' ') {
text.push(' ');
}
}
'>' => in_tag = false,
_ if !in_tag => text.push(c),
_ => {}
}
}
let _ = i;
let collapsed = text.split_whitespace().collect::<Vec<_>>().join(" ");
collapsed.chars().take(PAGE_TEXT_LIMIT).collect()
}
const CHROMIUM: &str = "chromium-browser --headless=new --no-sandbox --disable-gpu \
--disable-dev-shm-usage --virtual-time-budget=3000";
pub struct BrowserGoto;
#[async_trait::async_trait]
impl Tool for BrowserGoto {
fn descriptor(&self) -> ToolDescriptor {
ToolDescriptor {
name: "browser.goto".into(),
description: "Open a web page in your browser and read its content. \
The viewport screenshot appears in your Computer's Browser app."
.into(),
input_schema: json!({
"type": "object",
"properties": {
"url": { "type": "string", "description": "Absolute http(s) URL" }
},
"required": ["url"]
}),
}
}
fn effects(&self) -> &'static [Effect] {
&[]
}
fn output_taint(&self) -> Option<TaintSource> {
Some(TaintSource::Web)
}
async fn execute(
&self,
ctx: &ToolContext,
input: serde_json::Value,
) -> Result<serde_json::Value, String> {
let url = input["url"].as_str().ok_or("browser.goto requires 'url'")?;
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err("only http(s) URLs can be opened".into());
}
let browser = ctx
.browser
.as_ref()
.ok_or("no browser runtime is configured on this deployment")?;
let quoted = format!("'{}'", url.replace('\'', "%27"));
let prelude = "mkdir -p /home/agent/tmp && export TMPDIR=/home/agent/tmp HOME=/home/agent";
let dom = browser
.exec(
ctx.agent_id,
&format!("{prelude} && {CHROMIUM} --dump-dom {quoted} 2>/dev/null"),
)
.await?;
if dom.exit_code != 0 {
return Err(format!("navigation failed: {}", dom.stderr));
}
let content = html_to_text(&dom.stdout);
// Best-effort viewport capture for the Browser app.
let shot = browser
.exec(
ctx.agent_id,
&format!(
"{prelude} && cd /home/agent && {CHROMIUM} --window-size=1280,800 \
--screenshot=/home/agent/viewport.png {quoted} 2>/dev/null \
&& base64 < /home/agent/viewport.png"
),
)
.await?;
let mut screenshot_stored = false;
if shot.exit_code == 0 {
let raw: String = shot.stdout.split_whitespace().collect();
if let Ok(png) = base64_decode(&raw) {
let key = format!("{}/browser/{}/viewport.png", ctx.workspace_id, ctx.agent_id);
screenshot_stored = ctx.blob.put(&key, &png).await.is_ok();
}
}
Ok(json!({
"url": url,
"content": content,
"screenshot_stored": screenshot_stored,
}))
}
}
/// Minimal RFC 4648 decoder — the only base64 use in the workspace.
fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut value_of = [255u8; 256];
for (i, c) in ALPHABET.iter().enumerate() {
value_of[*c as usize] = i as u8;
}
let bytes: Vec<u8> = input.bytes().filter(|b| *b != b'=').collect();
let mut out = Vec::with_capacity(bytes.len() * 3 / 4);
for chunk in bytes.chunks(4) {
let mut accum: u32 = 0;
for b in chunk {
let v = value_of[*b as usize];
if v == 255 {
return Err("invalid base64".into());
}
accum = (accum << 6) | u32::from(v);
}
match chunk.len() {
4 => out.extend_from_slice(&accum.to_be_bytes()[1..4]),
3 => out.extend_from_slice(&((accum << 6).to_be_bytes())[1..3]),
2 => out.push(((accum << 12).to_be_bytes())[1]),
_ => return Err("truncated base64".into()),
}
}
Ok(out)
}
+3
View File
@@ -3,6 +3,7 @@
//! required before execution. Tool outputs may declare a taint source — //! required before execution. Tool outputs may declare a taint source —
//! untrusted content the run loop tracks (§15 untrusted-by-default). //! untrusted content the run loop tracks (§15 untrusted-by-default).
mod browser;
mod chat; mod chat;
mod clock; mod clock;
mod email; mod email;
@@ -41,6 +42,7 @@ pub struct ToolContext {
pub broker_socket: Option<std::path::PathBuf>, pub broker_socket: Option<std::path::PathBuf>,
pub slack_base_url: String, pub slack_base_url: String,
pub sandboxes: Option<Arc<crate::SandboxManager>>, pub sandboxes: Option<Arc<crate::SandboxManager>>,
pub browser: Option<Arc<crate::SandboxManager>>,
} }
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -82,6 +84,7 @@ impl Default for ToolRegistry {
registry.register(Arc::new(RoutineSchedule)); registry.register(Arc::new(RoutineSchedule));
registry.register(Arc::new(ChatSend)); registry.register(Arc::new(ChatSend));
registry.register(Arc::new(ChatInbox)); registry.register(Arc::new(ChatInbox));
registry.register(Arc::new(browser::BrowserGoto));
registry.register(Arc::new(shell::ShellExec)); registry.register(Arc::new(shell::ShellExec));
registry.register(Arc::new(SlackPost)); registry.register(Arc::new(SlackPost));
registry registry
+190
View File
@@ -0,0 +1,190 @@
//! The browser tool, end to end with REAL Chromium in the egress-enabled
//! browser container: navigate to a real local page, return its text
//! tainted `web`, store a viewport screenshot — and prove the §15 chain:
//! a gated action AFTER browsing carries the web taint into its approval.
use std::process::Command;
use std::sync::Arc;
use tc_domain::{
AccessPolicy, Agent, AgentId, AgentStatus, Role, User, UserId, Workspace, WorkspaceId,
};
use tc_llm::ScriptedProvider;
use tc_runtime::{RunEventBody, Runtime, RuntimeConfig, SandboxManager};
use tc_sandbox::DockerDriver;
const BROWSER_IMAGE: &str = "teamclaw/agent-browser:dev";
fn scenario(url: &str) -> String {
format!(
r#"
[[scenario]]
marker = "[[scenario:research]]"
[[scenario.turns]]
events = [
{{ type = "tool_use", name = "browser.goto", input = {{ url = "{url}" }} }},
]
[[scenario.turns]]
events = [
{{ type = "tool_use", name = "email.send", input = {{ to = "[email protected]", subject = "Findings", body = "Summary of the page." }} }},
]
[[scenario.turns]]
events = [
{{ type = "text", text = "Done." }},
]
"#
)
}
fn ensure_browser_image() {
let exists = Command::new("docker")
.args(["image", "inspect", BROWSER_IMAGE])
.output()
.expect("docker available")
.status
.success();
if !exists {
let root = env!("CARGO_MANIFEST_DIR");
let status = Command::new("docker")
.args([
"build",
"-t",
BROWSER_IMAGE,
&format!("{root}/../../images/agent-browser"),
])
.status()
.expect("docker build runs");
assert!(status.success(), "agent-browser image build failed");
}
}
/// A real page served on all interfaces so the container can reach it.
async fn spawn_page_server() -> String {
let app = axum::Router::new().route(
"/page",
axum::routing::get(|| async {
axum::response::Html(
"<html><head><title>Q2 Numbers</title></head>\
<body><h1>Quarterly report</h1><p>Revenue up 14 percent.</p></body></html>",
)
}),
);
let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
format!("http://host.docker.internal:{port}/page")
}
#[tokio::test]
async fn browsing_returns_web_tainted_content_and_taints_later_gated_actions() {
ensure_browser_image();
let pool = tc_testkit::test_pool().await;
let url = spawn_page_server().await;
let ws = Workspace {
id: WorkspaceId::new(),
name: "Acme".into(),
plan: "team".into(),
};
tc_db::repo::workspaces::insert(&pool, &ws).await.unwrap();
let owner = User {
id: UserId::new(),
workspace_id: ws.id,
email: format!("{}@acme.test", UserId::new()),
role: Role::Owner,
display_name: "Owner".into(),
created_at: time::OffsetDateTime::UNIX_EPOCH,
};
tc_db::repo::users::insert(&pool, &owner).await.unwrap();
let agent = Agent {
id: AgentId::new(),
workspace_id: ws.id,
name: "Scout".into(),
job_title: "Analyst".into(),
system_prompt: String::new(),
avatar: String::new(),
accent: String::new(),
wallpaper: String::new(),
managed_by: owner.id,
status: AgentStatus::Online,
};
tc_db::repo::agents::insert(&pool, &agent, &AccessPolicy::default())
.await
.unwrap();
let driver: Arc<dyn tc_sandbox::SandboxDriver> =
Arc::new(DockerDriver::connect().expect("docker reachable"));
let browser = Arc::new(SandboxManager::new(driver, BROWSER_IMAGE).with_egress());
let blob = Arc::new(tc_files::LocalBlobStore::new(
std::env::temp_dir().join(format!("tc-brw-{}", uuid::Uuid::now_v7())),
));
let rt = Runtime::with_blob_store(
pool.clone(),
Arc::new(ScriptedProvider::from_toml(&scenario(&url)).unwrap()),
RuntimeConfig::basic("scripted", 1024).with_browser(browser.clone()),
blob.clone(),
);
let session = tc_db::repo::sessions::create(&pool, agent.id, ws.id, "Research")
.await
.unwrap();
let started = rt
.send_message(session.id, "research it [[scenario:research]]")
.await
.unwrap();
let mut rx = started.events;
let mut suspended = false;
while let Ok(envelope) = rx.recv().await {
match envelope.event {
RunEventBody::RunSuspended { .. } => {
suspended = true;
break;
}
RunEventBody::Error { .. } => break,
_ => {}
}
}
assert!(suspended, "email.send must gate after browsing");
// The browse step returned real page text, tainted web.
let (output, taint): (serde_json::Value, Vec<String>) = sqlx::query_as(
"SELECT s.output, s.taint FROM steps s
JOIN messages m ON m.id = s.message_id
WHERE m.session_id = $1 AND s.tool_name = 'browser.goto'",
)
.bind(session.id.as_uuid())
.fetch_one(&pool)
.await
.unwrap();
let text = output["content"].as_str().unwrap();
assert!(
text.contains("Revenue up 14 percent"),
"real chromium fetched the real page: {text}"
);
assert!(taint.contains(&"web".to_owned()), "taint: {taint:?}");
// The §15 chain: the approval for the LATER gated action carries the
// web taint — untrusted content can never quietly reach outward.
let pending = tc_safety::approvals::list_pending(&pool, ws.id)
.await
.unwrap();
assert_eq!(pending.len(), 1);
assert!(
pending[0].taint_sources.contains(&"web".to_owned()),
"approval taint: {:?}",
pending[0].taint_sources
);
// The viewport screenshot landed in the blob store as a real PNG.
use tc_files::BlobStore;
let key = format!("{}/browser/{}/viewport.png", ws.id, agent.id);
let png = blob.get(&key).await.expect("screenshot stored");
assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n", "PNG magic");
browser.shutdown().await;
}
+5 -1
View File
@@ -245,5 +245,9 @@ async fn inbox_content_taints_the_run_and_its_approvals() {
.unwrap(); .unwrap();
let steps = &history.last().unwrap().steps; let steps = &history.last().unwrap().steps;
assert_eq!(steps[0].tool_name.as_deref(), Some("chat.inbox")); assert_eq!(steps[0].tool_name.as_deref(), Some("chat.inbox"));
assert!(steps[0].taint.is_empty(), "inbox read itself is pre-taint"); assert_eq!(
steps[0].taint,
vec!["inter_agent"],
"the step that produced untrusted output carries its taint"
);
} }
+1
View File
@@ -141,6 +141,7 @@ async fn slack_post_blocks_then_the_broker_executes_exactly_once() {
model: "scripted".into(), model: "scripted".into(),
max_tokens: 1024, max_tokens: 1024,
sandboxes: None, sandboxes: None,
browser: None,
broker_socket: Some(socket), broker_socket: Some(socket),
slack_base_url: sink_url, slack_base_url: sink_url,
}, },
+8 -1
View File
@@ -69,7 +69,14 @@ impl SandboxDriver for DockerDriver {
.into_iter() .into_iter()
.collect(), .collect(),
), ),
network_mode: Some("none".into()), network_mode: Some(if spec.egress { "bridge" } else { "none" }.into()),
// Lets browser containers reach host-published test pages on
// Linux engines; Docker Desktop resolves this name natively.
extra_hosts: if spec.egress {
Some(vec!["host.docker.internal:host-gateway".into()])
} else {
None
},
memory: Some(spec.memory_bytes), memory: Some(spec.memory_bytes),
nano_cpus: Some(spec.nano_cpus), nano_cpus: Some(spec.nano_cpus),
pids_limit: Some(spec.pids_limit), pids_limit: Some(spec.pids_limit),
+4
View File
@@ -11,6 +11,10 @@ pub struct SandboxSpec {
pub memory_bytes: i64, pub memory_bytes: i64,
pub nano_cpus: i64, pub nano_cpus: i64,
pub pids_limit: i64, pub pids_limit: i64,
/// Egress-enabled containers exist ONLY for the browser tool: they
/// hold no credentials and have no broker route; their output is
/// tainted `web`. Everything else runs with no network at all.
pub egress: bool,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
+1
View File
@@ -53,6 +53,7 @@ async fn spawn(suffix: &str) -> (K8sDriver, tc_sandbox::SandboxHandle) {
memory_bytes: 256 * 1024 * 1024, memory_bytes: 256 * 1024 * 1024,
nano_cpus: 1_000_000_000, nano_cpus: 1_000_000_000,
pids_limit: 128, pids_limit: 128,
egress: false,
}; };
let handle = driver.provision(&spec).await.expect("pod provisions"); let handle = driver.provision(&spec).await.expect("pod provisions");
(driver, handle) (driver, handle)
+1
View File
@@ -43,6 +43,7 @@ async fn spawn(name_suffix: &str) -> (DockerDriver, tc_sandbox::SandboxHandle) {
memory_bytes: 256 * 1024 * 1024, memory_bytes: 256 * 1024 * 1024,
nano_cpus: 1_000_000_000, nano_cpus: 1_000_000_000,
pids_limit: 128, pids_limit: 128,
egress: false,
}; };
// Clean leftovers from interrupted runs, then provision fresh. // Clean leftovers from interrupted runs, then provision fresh.
let _ = driver.destroy_by_name(&spec.name).await; let _ = driver.destroy_by_name(&spec.name).await;
@@ -1,11 +1,41 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
/** The live agent browser (§7.1). The CDP-backed viewport lands with the /** The agent browser (§7.1): shows the latest viewport captured by the
* sandboxed browser tool; until an active session exists this is the * sandboxed browser tool (browser.goto), polling while open; the spec's
* spec's empty state with full chrome. */ * empty state with full chrome until the agent has browsed. */
export default function BrowserApp({ agent }: { agent: Agent }) { export default function BrowserApp({ agent }: { agent: Agent }) {
const [viewport, setViewport] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout>;
async function poll() {
const res = await fetch(`/api/claws/${agent.id}/browser/viewport.png`, {
cache: "no-store",
});
if (cancelled) return;
if (res.ok) {
const blob = await res.blob();
if (!cancelled) {
setViewport((previous) => {
if (previous) URL.revokeObjectURL(previous);
return URL.createObjectURL(blob);
});
}
}
timer = setTimeout(poll, 3000);
}
poll();
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [agent.id]);
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="flex items-center gap-1.5 border-b border-border px-2 py-1.5"> <div className="flex items-center gap-1.5 border-b border-border px-2 py-1.5">
@@ -29,7 +59,7 @@ export default function BrowserApp({ agent }: { agent: Agent }) {
aria-label="Address" aria-label="Address"
className="flex-1 rounded-(--radius-button) border border-input bg-subtle px-3 py-1 text-xs text-muted-foreground" className="flex-1 rounded-(--radius-button) border border-input bg-subtle px-3 py-1 text-xs text-muted-foreground"
> >
newtab {viewport ? "agent session" : "newtab"}
</span> </span>
<button <button
type="button" type="button"
@@ -40,15 +70,27 @@ export default function BrowserApp({ agent }: { agent: Agent }) {
⟳ ⟳
</button> </button>
</div> </div>
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center"> {viewport ? (
<span aria-hidden className="text-2xl"> <div className="flex-1 overflow-auto bg-background">
🌐 {/* Blob object URL from our own API; next/image adds nothing here. */}
</span> {/* eslint-disable-next-line @next/next/no-img-element */}
<p className="text-sm font-medium">No active browsing session</p> <img
<p className="max-w-60 text-xs text-muted-foreground"> src={viewport}
When {agent.name} browses the web, the live session appears here. alt={`${agent.name}'s browser viewport`}
</p> className="w-full"
</div> />
</div>
) : (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center">
<span aria-hidden className="text-2xl">
🌐
</span>
<p className="text-sm font-medium">No active browsing session</p>
<p className="max-w-60 text-xs text-muted-foreground">
When {agent.name} browses the web, the live session appears here.
</p>
</div>
)}
</div> </div>
); );
} }
+15
View File
@@ -0,0 +1,15 @@
# Browser container for the browser.goto tool: headless Chromium, same
# non-root hardening as agent-base. This container HAS network egress —
# it holds no credentials and no broker route; everything it returns is
# tainted `web` (§15: data, never instructions).
FROM alpine:3.21
RUN apk add --no-cache chromium font-noto \
&& addgroup -g 10001 agent \
&& adduser -D -u 10001 -G agent agent \
# No setuid/setgid binaries: privilege escalation surface to zero.
&& find / -xdev -perm /6000 -type f -exec chmod a-s {} + 2>/dev/null || true
USER 10001:10001
WORKDIR /home/agent
CMD ["sleep", "infinity"]