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:
co-authored by
Claude Fable 5
parent
7392ce1d08
commit
4f253bec93
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
Q2 revenue is up 14%.
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
Reference in New Issue
Block a user