diff --git a/crates/botloftd/src/browser/launch.rs b/crates/botloftd/src/browser/launch.rs index 3ca09da4..a5022247 100644 --- a/crates/botloftd/src/browser/launch.rs +++ b/crates/botloftd/src/browser/launch.rs @@ -226,6 +226,20 @@ fn log_text(line: &str) -> String { text.trim().to_owned() } +/// The DevTools port written in each profile under `profiles`. +pub(super) fn ports_in(profiles: &Path) -> Vec { + let Ok(profiles) = std::fs::read_dir(profiles) else { + return Vec::new(); + }; + profiles + .flatten() + .filter_map(|profile| { + let text = std::fs::read_to_string(profile.path().join(PORT_FILE)).ok()?; + text.lines().next()?.trim().parse().ok() + }) + .collect() +} + /// `DevToolsActivePort` holds the port and the browser's WebSocket path, one /// per line. The file may be read half-written. fn devtools_url(text: &str) -> Option { @@ -240,6 +254,23 @@ fn devtools_url(text: &str) -> Option { mod tests { use super::*; + #[test] + fn every_profile_gives_its_port() { + let dir = tempfile::tempdir().expect("tempdir"); + for (bot, text) in [ + ("bot_a", "9222\n/devtools/browser/a\n"), + ("bot_b", "61000\n"), + ] { + std::fs::create_dir(dir.path().join(bot)).expect("profile"); + std::fs::write(dir.path().join(bot).join(PORT_FILE), text).expect("port"); + } + std::fs::create_dir(dir.path().join("bot_c")).expect("no port yet"); + let mut ports = ports_in(dir.path()); + ports.sort_unstable(); + assert_eq!(ports, [9222, 61000]); + assert!(ports_in(&dir.path().join("missing")).is_empty()); + } + #[test] fn reads_the_port_file() { assert_eq!( diff --git a/crates/botloftd/src/browser/mod.rs b/crates/botloftd/src/browser/mod.rs index 896e0954..70fe710c 100644 --- a/crates/botloftd/src/browser/mod.rs +++ b/crates/botloftd/src/browser/mod.rs @@ -161,6 +161,12 @@ impl Browsers { } } + /// The DevTools ports of the bots' browsers, read from every profile: + /// no page may open them (spec 21.5). A stale file only adds a port. + pub fn devtools_ports(&self) -> Vec { + launch::ports_in(&self.profiles) + } + fn slot<'a>(&self, slots: &'a mut HashMap, bot: &BotId) -> &'a mut Slot { slots.entry(bot.clone()).or_insert_with(|| Slot { calls: Arc::default(), diff --git a/crates/botloftd/src/browser/sites.rs b/crates/botloftd/src/browser/sites.rs index c0b3d00f..638f426e 100644 --- a/crates/botloftd/src/browser/sites.rs +++ b/crates/botloftd/src/browser/sites.rs @@ -107,6 +107,44 @@ pub fn site_of(url: &str) -> Option { (!host.is_empty()).then(|| host.to_owned()) } +/// The port of a loopback address (`localhost`, `127.x.x.x`, `[::1]` or +/// `0.0.0.0`), or the scheme's own when none is written. `None` for any +/// other address. +pub fn loopback_port(url: &str) -> Option { + let lower = url.to_ascii_lowercase(); + let (rest, default) = match lower.strip_prefix("https://") { + Some(rest) => (rest, 443), + None => (lower.strip_prefix("http://")?, 80), + }; + let authority = rest.split(['/', '?', '#']).next()?; + let host_port = authority.rsplit('@').next()?; + let (host, port) = match host_port.strip_prefix('[') { + Some(v6) => { + let (host, after) = v6.split_once(']')?; + (host, after.strip_prefix(':')) + } + None => match host_port.split_once(':') { + Some((host, port)) => (host, Some(port)), + None => (host_port, None), + }, + }; + let host = host.trim_end_matches('.'); + let loopback = host == "localhost" + || host.ends_with(".localhost") + || host == "::1" + || host == "0.0.0.0" + || host + .parse::() + .is_ok_and(|ip| ip.is_loopback()); + if !loopback { + return None; + } + match port { + None | Some("") => Some(default), + Some(port) => port.parse().ok(), + } +} + /// Whether allowing `allowed` also allows `site`. pub fn covers(allowed: &str, site: &str) -> bool { site == allowed diff --git a/crates/botloftd/src/browser/sites_tests.rs b/crates/botloftd/src/browser/sites_tests.rs index e5704cc7..038a7a5d 100644 --- a/crates/botloftd/src/browser/sites_tests.rs +++ b/crates/botloftd/src/browser/sites_tests.rs @@ -140,3 +140,17 @@ fn what_the_owner_types_opens_only_as_a_web_address() { assert_eq!(open("https://"), None); assert_eq!(open(""), None); } + +#[test] +fn loopback_addresses_give_their_port() { + assert_eq!(loopback_port("http://127.0.0.1:9222/json/list"), Some(9222)); + assert_eq!(loopback_port("http://LOCALHOST:3000"), Some(3000)); + assert_eq!(loopback_port("http://a@127.1.2.3:4000/"), Some(4000)); + assert_eq!(loopback_port("http://[::1]:9222"), Some(9222)); + assert_eq!(loopback_port("http://0.0.0.0:80"), Some(80)); + assert_eq!(loopback_port("https://app.localhost/x"), Some(443)); + assert_eq!(loopback_port("http://localhost"), Some(80)); + assert_eq!(loopback_port("http://example.com:9222"), None); + assert_eq!(loopback_port("http://127.0.0.1.example.com:9222"), None); + assert_eq!(loopback_port("file:///C:/x.html"), None); +} diff --git a/crates/botloftd/src/service/bots.rs b/crates/botloftd/src/service/bots.rs index e4726526..c266da08 100644 --- a/crates/botloftd/src/service/bots.rs +++ b/crates/botloftd/src/service/bots.rs @@ -114,7 +114,9 @@ pub(crate) fn insert( created_at: now_ms(), archived_at: None, }; - workspace::prepare_bot(daemon.workspace_env(), crew, &record).map_err(ApiError::Workspace)?; + let crews = store.crews(true)?; + workspace::prepare_bot(daemon.workspace_env(), crew, &crews, &record) + .map_err(ApiError::Workspace)?; store.insert_bot(&record)?; Ok(record) } diff --git a/crates/botloftd/src/service/crews.rs b/crates/botloftd/src/service/crews.rs index 5abf7549..096aa7e3 100644 --- a/crates/botloftd/src/service/crews.rs +++ b/crates/botloftd/src/service/crews.rs @@ -32,7 +32,7 @@ pub fn create(daemon: &Daemon, params: CrewsCreateParams) -> ApiResult { let chosen = params .work_folder .as_deref() - .map(|input| choose_folder(daemon, input)) + .map(|input| choose_folder(daemon, &store, &slug, None, input)) .transpose()?; let lead = params @@ -85,13 +85,13 @@ pub fn rename(daemon: &Daemon, params: CrewsRenameParams) -> ApiResult { /// Moves the crew to another work folder, or back to `shared`. Each bot /// restarts to reach it once nothing is in progress (spec 7.4). pub fn set_work_folder(daemon: &Daemon, params: CrewsSetWorkFolderParams) -> ApiResult { + let store = daemon.store(); + let mut crew = active(&store, ¶ms.crew_id)?; let chosen = params .work_folder .as_deref() - .map(|input| choose_folder(daemon, input)) + .map(|input| choose_folder(daemon, &store, &crew.slug, Some(&crew.id), input)) .transpose()?; - let store = daemon.store(); - let mut crew = active(&store, ¶ms.crew_id)?; if crew.work_folder_chosen == chosen.is_some() && chosen .as_deref() @@ -176,8 +176,22 @@ pub(crate) fn changed(daemon: &Daemon, crew: Crew) -> Crew { crew } -fn choose_folder(daemon: &Daemon, input: &str) -> ApiResult { - let path = workspace::folder::choose(&daemon.paths, input).map_err(ApiError::validation)?; +/// A work folder of the crew `slug` (`id` once it exists), apart from every +/// other crew's folders. +fn choose_folder( + daemon: &Daemon, + store: &Store, + slug: &str, + id: Option<&CrewId>, + input: &str, +) -> ApiResult { + let others: Vec = store + .crews(true)? + .into_iter() + .filter(|crew| Some(&crew.id) != id) + .collect(); + let path = workspace::folder::choose(&daemon.paths, slug, &others, input) + .map_err(ApiError::validation)?; Ok(path.display().to_string()) } diff --git a/crates/botloftd/src/service/files/mod.rs b/crates/botloftd/src/service/files/mod.rs index 6aa85140..9061263c 100644 --- a/crates/botloftd/src/service/files/mod.rs +++ b/crates/botloftd/src/service/files/mod.rs @@ -27,11 +27,24 @@ const PREVIEW_MAX_BYTES: u64 = 20 * 1024 * 1024; /// Generated for the bot by the daemon: not something it made for the owner. const WORKSPACE_SKIP: [&str; 2] = ["attachments", "CLAUDE.md"]; -/// Where a bot's files live, and what it wrote by name. +/// Where a bot's files live, what it wrote by name, and where its writes +/// never count: Botloft's data and the other crews' folders (spec 7.5). struct Places { work: PathBuf, workspace: PathBuf, written: Vec, + fenced: Vec, +} + +impl Places { + /// A file the bot wrote, unless it is behind a fence. + fn wrote(&self, path: &Path) -> bool { + !self + .fenced + .iter() + .filter_map(|folder| folder.canonicalize().ok()) + .any(|folder| path.starts_with(folder)) + } } fn places(daemon: &Daemon, bot_id: &BotId) -> ApiResult<(BotRecord, Places)> { @@ -39,11 +52,13 @@ fn places(daemon: &Daemon, bot_id: &BotId) -> ApiResult<(BotRecord, Places)> { let bot = bots::find(&store, bot_id)?; let crew = crews::find(&store, &bot.crew_id)?; let written = store.written_files(&bot.id, WRITTEN_MAX)?; + let crews = store.crews(true)?; drop(store); let places = Places { work: daemon.paths.work_folder(&crew), workspace: daemon.paths.bot_workspace(&crew.slug, &bot.slug), written, + fenced: crate::workspace::fences(&daemon.paths, &crew, &crews), }; Ok((bot, places)) } @@ -74,6 +89,7 @@ pub fn list(daemon: &Daemon, params: FilesListParams) -> ApiResult> let path = PathBuf::from(path); if let Ok(meta) = std::fs::metadata(&path) && meta.is_file() + && path.canonicalize().is_ok_and(|real| places.wrote(&real)) { found.entry(key(&path)).or_insert_with(|| Found { size: meta.len(), @@ -141,7 +157,8 @@ fn reach(places: &Places, asked: &Path) -> Result<(PathBuf, std::fs::Metadata), .written .iter() .filter_map(|file| Path::new(file).canonicalize().ok()) - .any(|file| file == path); + .any(|file| file == path) + && places.wrote(&path); if !asked.is_absolute() || !(inside || written) { return Err(Refused::Outside); } diff --git a/crates/botloftd/src/supervisor/spawn.rs b/crates/botloftd/src/supervisor/spawn.rs index 7873d5ef..d31affc5 100644 --- a/crates/botloftd/src/supervisor/spawn.rs +++ b/crates/botloftd/src/supervisor/spawn.rs @@ -107,7 +107,8 @@ pub(super) fn launch_spec( session: Option<&str>, ) -> io::Result<(SpawnSpec, Launch)> { // settings.json and the rules are rewritten on every start. - let workspace = workspace::prepare_bot(daemon.workspace_env(), crew, bot)?; + let crews = daemon.store().crews(true).map_err(io::Error::other)?; + let workspace = workspace::prepare_bot(daemon.workspace_env(), crew, &crews, bot)?; let token = secrets::random_token()?; let (session, resumed) = match session { Some(session) => (session.to_owned(), true), diff --git a/crates/botloftd/src/tools/browser_sites.rs b/crates/botloftd/src/tools/browser_sites.rs index 731c2936..6f34f622 100644 --- a/crates/botloftd/src/tools/browser_sites.rs +++ b/crates/botloftd/src/tools/browser_sites.rs @@ -13,6 +13,15 @@ use crate::approvals::{self, Answer}; use crate::browser::{Session, sites}; use crate::state::Daemon; +const CONTROL_PORT: &str = "That address is the control port of a browser Botloft runs, which no \ + page may open, in any mode."; + +/// Whether `url` is the DevTools port of a bot's browser, its own or +/// another crew's (spec 21.5). +fn control_port(daemon: &Daemon, url: &str) -> bool { + sites::loopback_port(url).is_some_and(|port| daemon.browsers.devtools_ports().contains(&port)) +} + /// Whether the bot may use `site`, asking the owner the first time when /// its mode asks first (spec 21.5). pub(super) async fn allowed( @@ -23,6 +32,9 @@ pub(super) async fn allowed( site: &str, url: &str, ) -> Result<(), String> { + if control_port(daemon, url) { + return Err(CONTROL_PORT.to_owned()); + } if matches!( bot.mode, PermissionMode::Auto | PermissionMode::BypassPermissions @@ -71,6 +83,10 @@ pub(super) async fn page_allowed( if sites::is_file(url) { return outside_file(session, bot, url).await; } + if control_port(daemon, url) { + let _ = session.open("about:blank").await; + return Err(format!("{CONTROL_PORT} It was closed.")); + } match sites::site_of(url) { Some(site) => allowed(daemon, id, generation, bot, &site, url).await, None => Ok(()), diff --git a/crates/botloftd/src/workspace/files.rs b/crates/botloftd/src/workspace/files.rs index 19a65c8e..f81a68d1 100644 --- a/crates/botloftd/src/workspace/files.rs +++ b/crates/botloftd/src/workspace/files.rs @@ -1,6 +1,6 @@ //! Contents of the files the daemon generates in a bot's workspace. -use std::path::Path; +use std::path::{Path, PathBuf}; use std::time::Duration; use botloft_core::protocol::Crew; @@ -15,14 +15,19 @@ use crate::paths::permission_rule_path; const MCP_TIMEOUT_MARGIN: Duration = Duration::from_secs(120); /// `.claude/settings.json` (spec 7.5): keeps the bot out of the daemon's -/// secrets, and the owner's own Claude Code memory out of the bot. Deny -/// rules apply even in a folder nobody trusted. -pub fn settings_json(botloft_home: &Path, crew_dir: &Path) -> Value { +/// secrets, out of `fenced` (Botloft's data and the other crews' folders, +/// see `fences`) and the owner's own Claude Code memory out of the bot. +/// Deny rules apply even in a folder nobody trusted, and in every mode. +pub fn settings_json(botloft_home: &Path, crew_dir: &Path, fenced: &[PathBuf]) -> Value { let secrets = permission_rule_path(&botloft_home.join("secrets")); + let mut deny = vec![format!("Read({secrets}/**)")]; + for folder in fenced { + let folder = permission_rule_path(folder); + deny.push(format!("Read({folder}/**)")); + deny.push(format!("Edit({folder}/**)")); + } json!({ - "permissions": { - "deny": [format!("Read({secrets}/**)")], - }, + "permissions": { "deny": deny }, // Instruction files above the crew's folder are the owner's. "claudeMdExcludes": memory::excludes_above(crew_dir), // The bot's memory is the `CLAUDE.md` in its folder. Claude Code's @@ -210,18 +215,28 @@ mod tests { use super::*; #[test] - fn settings_deny_the_secrets_folder_and_nothing_else() { + fn settings_deny_the_secrets_and_the_fenced_folders() { let home = Path::new(r"C:\Users\ana\AppData\Local\Botloft"); - let settings = settings_json(home, Path::new("/ws/site")); + let settings = settings_json(home, Path::new("/ws/site"), &[]); assert_eq!( settings["permissions"], json!({ "deny": ["Read(//c/Users/ana/AppData/Local/Botloft/secrets/**)"] }) ); + let blog = PathBuf::from("C:/Users/ana/Botloft/blog"); + let settings = settings_json(home, Path::new("/ws/site"), &[blog]); + assert_eq!( + settings["permissions"]["deny"], + json!([ + "Read(//c/Users/ana/AppData/Local/Botloft/secrets/**)", + "Read(//c/Users/ana/Botloft/blog/**)", + "Edit(//c/Users/ana/Botloft/blog/**)", + ]) + ); } #[test] fn settings_keep_the_owners_memory_out_of_the_bot() { - let settings = settings_json(Path::new("/data"), Path::new("/ws/site")); + let settings = settings_json(Path::new("/data"), Path::new("/ws/site"), &[]); assert_eq!( settings["claudeMdExcludes"], json!(memory::excludes_above(Path::new("/ws/site"))) diff --git a/crates/botloftd/src/workspace/folder.rs b/crates/botloftd/src/workspace/folder.rs index 03dffbbe..57f2e453 100644 --- a/crates/botloftd/src/workspace/folder.rs +++ b/crates/botloftd/src/workspace/folder.rs @@ -5,11 +5,14 @@ use std::io; use std::path::{Component, Path, PathBuf}; +use botloft_core::protocol::Crew; + use crate::paths::Paths; -/// Checks `input` and creates the folder if it is missing. The error is a -/// sentence for the owner. -pub fn choose(paths: &Paths, input: &str) -> Result { +/// Checks `input` for the crew with folder `slug` and creates the folder if +/// it is missing. `others` are the other crews, archived ones too: their +/// folders stay on disk. The error is a sentence for the owner. +pub fn choose(paths: &Paths, slug: &str, others: &[Crew], input: &str) -> Result { let input = input.trim(); if input.is_empty() { return Err("the work folder must not be empty".to_owned()); @@ -31,6 +34,22 @@ pub fn choose(paths: &Paths, input: &str) -> Result { if contains(&path, &paths.workspaces_root) { return Err("the work folder cannot contain the folders of every bot".to_owned()); } + // Inside the bots' folders, only in the crew's own. + if contains(&paths.workspaces_root, &path) && !contains(&paths.crew_dir(slug), &path) { + return Err("the work folder cannot be inside another crew's folder".to_owned()); + } + for other in others { + let mut theirs = vec![paths.crew_dir(&other.slug)]; + if other.work_folder_chosen { + theirs.push(PathBuf::from(&other.work_folder)); + } + if theirs.iter().any(|folder| overlaps(&path, folder)) { + return Err(format!( + "the crew {} already works in that folder or one around it: each crew needs a folder of its own", + other.name + )); + } + } create(&path).map_err(|err| format!("could not create the work folder: {err}"))?; Ok(path) } @@ -67,7 +86,7 @@ pub(crate) fn contains(outer: &Path, inner: &Path) -> bool { inner.len() >= outer.len() && inner[..outer.len()] == outer[..] } -fn overlaps(a: &Path, b: &Path) -> bool { +pub(crate) fn overlaps(a: &Path, b: &Path) -> bool { contains(a, b) || contains(b, a) } @@ -83,7 +102,13 @@ mod tests { fn a_folder_of_the_owner_is_created_and_kept() { let dir = tempfile::tempdir().expect("tempdir"); let wanted = dir.path().join("Projects").join("Site"); - let chosen = choose(&paths(dir.path()), wanted.to_str().expect("utf-8")).expect("chosen"); + let chosen = choose( + &paths(dir.path()), + "site", + &[], + wanted.to_str().expect("utf-8"), + ) + .expect("chosen"); assert_eq!(chosen, wanted); assert!(wanted.is_dir()); } @@ -101,18 +126,60 @@ mod tests { dir.path().display().to_string(), ]; for input in refused { - assert!(choose(&paths, &input).is_err(), "{input:?} was accepted"); + assert!( + choose(&paths, "site", &[], &input).is_err(), + "{input:?} was accepted" + ); } let root = dir.path().ancestors().last().expect("root"); - assert!(choose(&paths, root.to_str().expect("utf-8")).is_err()); + assert!(choose(&paths, "site", &[], root.to_str().expect("utf-8")).is_err()); + } + + fn crew(name: &str, work_folder: Option<&Path>) -> Crew { + Crew { + id: botloft_core::ids::CrewId::generate(), + name: name.to_owned(), + slug: name.to_lowercase(), + work_folder: work_folder + .map(|f| f.display().to_string()) + .unwrap_or_default(), + work_folder_chosen: work_folder.is_some(), + lead_bot_id: None, + paused: false, + created_at: 0, + archived_at: None, + } } #[test] - fn a_folder_inside_the_crew_folders_is_fine() { + fn a_folder_inside_the_crews_own_folder_is_fine() { let dir = tempfile::tempdir().expect("tempdir"); let paths = paths(dir.path()); let inside = paths.workspaces_root.join("site").join("out"); - assert!(choose(&paths, inside.to_str().expect("utf-8")).is_ok()); + assert!(choose(&paths, "site", &[], inside.to_str().expect("utf-8")).is_ok()); + } + + #[test] + fn another_crews_folders_are_refused() { + let dir = tempfile::tempdir().expect("tempdir"); + let paths = paths(dir.path()); + let shop = dir.path().join("Projects").join("Shop"); + let others = [crew("Blog", None), crew("Store", Some(&shop))]; + let refused = [ + paths.workspaces_root.join("blog").join("out"), + paths.workspaces_root.join("newcrew"), + shop.join("images"), + dir.path().join("Projects"), + ]; + for input in refused { + let input = input.display().to_string(); + assert!( + choose(&paths, "site", &others, &input).is_err(), + "{input:?} was accepted" + ); + } + let apart = dir.path().join("Projects").join("Site"); + assert!(choose(&paths, "site", &others, apart.to_str().expect("utf-8")).is_ok()); } #[cfg(windows)] diff --git a/crates/botloftd/src/workspace/mod.rs b/crates/botloftd/src/workspace/mod.rs index b667eef7..391aa021 100644 --- a/crates/botloftd/src/workspace/mod.rs +++ b/crates/botloftd/src/workspace/mod.rs @@ -31,8 +31,43 @@ pub fn prepare_crew(paths: &Paths, crew: &Crew) -> io::Result<()> { std::fs::create_dir_all(paths.work_folder(crew)) } -/// Creates the workspace and writes every generated file. Returns the path. -pub fn prepare_bot(env: WorkspaceEnv<'_>, crew: &Crew, bot: &BotRecord) -> io::Result { +/// What a bot of `crew` must not read or edit with its own tools (spec 7.5): +/// Botloft's data and the folders of every other crew in `crews`, archived +/// ones too. A folder that holds one of the crew's own is left out, since +/// a deny rule would win over it; when Botloft's data holds the bots' +/// folders, only its own parts are fenced. +pub fn fences(paths: &Paths, crew: &Crew, crews: &[Crew]) -> Vec { + let own = [paths.crew_dir(&crew.slug), paths.work_folder(crew)]; + let apart = |folder: &PathBuf| !own.iter().any(|mine| folder::overlaps(folder, mine)); + let mut fenced = Vec::new(); + if apart(&paths.home) && !folder::contains(&paths.home, &paths.workspaces_root) { + fenced.push(paths.home.clone()); + } else { + let parts = ["browsers", "logs", "run", "local-backup"]; + fenced.extend(parts.iter().map(|part| paths.home.join(part))); + } + for other in crews.iter().filter(|other| other.id != crew.id) { + let mut theirs = vec![paths.crew_dir(&other.slug)]; + if other.work_folder_chosen { + theirs.push(PathBuf::from(&other.work_folder)); + } + for folder in theirs { + if apart(&folder) && !fenced.contains(&folder) { + fenced.push(folder); + } + } + } + fenced +} + +/// Creates the workspace and writes every generated file. `crews` are all +/// the crews, for `fences`. Returns the path. +pub fn prepare_bot( + env: WorkspaceEnv<'_>, + crew: &Crew, + crews: &[Crew], + bot: &BotRecord, +) -> io::Result { prepare_crew(env.paths, crew)?; let dir = env.paths.bot_workspace(&crew.slug, &bot.slug); std::fs::create_dir_all(dir.join(".claude").join("rules"))?; @@ -44,7 +79,11 @@ pub fn prepare_bot(env: WorkspaceEnv<'_>, crew: &Crew, bot: &BotRecord) -> io::R } write_json( &dir.join(".claude").join("settings.json"), - &files::settings_json(&env.paths.home, &env.paths.crew_dir(&crew.slug)), + &files::settings_json( + &env.paths.home, + &env.paths.crew_dir(&crew.slug), + &fences(env.paths, crew, crews), + ), )?; write_json( &dir.join(".botloft").join("mcp.json"), @@ -128,7 +167,7 @@ mod tests { port: 45710, approval_timeout: Duration::from_secs(3600), }; - let ws = prepare_bot(env, &crew, &bot).expect("prepare"); + let ws = prepare_bot(env, &crew, &[], &bot).expect("prepare"); assert_eq!(ws, paths.bot_workspace("site", "writer")); assert!(paths.shared_dir("site").is_dir()); @@ -165,12 +204,12 @@ mod tests { port: 45710, approval_timeout: Duration::from_secs(3600), }; - let ws = prepare_bot(env, &crew, &bot).expect("prepare"); + let ws = prepare_bot(env, &crew, &[], &bot).expect("prepare"); std::fs::write(ws.join("CLAUDE.md"), "my notes").expect("edit memory"); bot.name = "Lead Writer".to_owned(); bot.handle = "lead-writer".to_owned(); - prepare_bot(env, &crew, &bot).expect("prepare again"); + prepare_bot(env, &crew, &[], &bot).expect("prepare again"); let memory = std::fs::read_to_string(ws.join("CLAUDE.md")).expect("memory"); assert_eq!(memory, "my notes"); @@ -178,4 +217,48 @@ mod tests { assert!(rules.contains("`@lead-writer`")); assert!(!ws.join(".claude/rules/botloft.md.tmp").exists()); } + + #[test] + fn a_bot_is_fenced_from_botloft_data_and_the_other_crews() { + use botloft_core::ids::CrewId; + let paths = Paths::new( + PathBuf::from("/data/Botloft"), + PathBuf::from("/home/Botloft"), + ); + let crew = |name: &str, folder: Option<&str>| Crew { + id: CrewId::generate(), + name: name.to_owned(), + slug: name.to_lowercase(), + work_folder: folder.unwrap_or_default().to_owned(), + work_folder_chosen: folder.is_some(), + lead_bot_id: None, + paused: false, + created_at: 0, + archived_at: None, + }; + let site = crew("Site", Some("/projects/site")); + let crews = [ + site.clone(), + crew("Blog", None), + crew("Shop", Some("/projects/shop")), + // An old crew sharing a folder around Site's is left open. + crew("Old", Some("/projects")), + ]; + assert_eq!( + fences(&paths, &site, &crews), + [ + PathBuf::from("/data/Botloft"), + PathBuf::from("/home/Botloft/blog"), + PathBuf::from("/home/Botloft/shop"), + PathBuf::from("/projects/shop"), + PathBuf::from("/home/Botloft/old"), + ] + ); + + // Bots' folders inside Botloft's data: only its own parts. + let inside = Paths::new(PathBuf::from("/data"), PathBuf::from("/data/ws")); + let fenced = fences(&inside, &crew("Blog", None), &[]); + assert!(fenced.contains(&PathBuf::from("/data/browsers"))); + assert!(!fenced.contains(&PathBuf::from("/data"))); + } } diff --git a/crates/botloftd/tests/crew_isolation.rs b/crates/botloftd/tests/crew_isolation.rs new file mode 100644 index 00000000..cffeb96c --- /dev/null +++ b/crates/botloftd/tests/crew_isolation.rs @@ -0,0 +1,111 @@ +//! Crews kept apart (spec 5, 7.5): each crew's work folder is its own, and +//! a bot's settings fence it off from Botloft's data and the other crews' +//! folders. + +mod common; + +use common::TestDaemon; +use serde_json::{Value, json}; + +#[tokio::test] +async fn a_crew_cannot_take_another_crews_folder() { + let t = TestDaemon::start().await; + let mut app = t.session().await; + let base = t + .paths + .workspaces_root + .parent() + .expect("test folder") + .to_owned(); + let shop = base.join("Projects").join("Shop"); + let blog = app + .call( + "crews.create", + json!({ "name": "Blog", "workFolder": shop.to_string_lossy() }), + ) + .await + .expect("blog"); + + for folder in [ + shop.join("images"), + base.join("Projects"), + t.paths.crew_dir("blog").join("drafts"), + ] { + let refused = app + .call( + "crews.create", + json!({ "name": "Site", "workFolder": folder.to_string_lossy() }), + ) + .await + .expect_err("taken"); + assert!(refused.message.contains("folder"), "{}", refused.message); + } + let site = app + .call("crews.create", json!({ "name": "Site" })) + .await + .expect("site"); + let refused = app + .call( + "crews.setWorkFolder", + json!({ "crewId": site["id"], "workFolder": shop.join("x").to_string_lossy() }), + ) + .await + .expect_err("taken"); + assert!(refused.message.contains("Blog"), "{}", refused.message); + + // A crew keeps choosing inside its own folders. + let own = shop.join("out"); + app.call( + "crews.setWorkFolder", + json!({ "crewId": blog["id"], "workFolder": own.to_string_lossy() }), + ) + .await + .expect("its own"); +} + +#[tokio::test] +async fn a_bots_settings_deny_the_other_crews_and_botloft_data() { + let t = TestDaemon::start().await; + let mut app = t.session().await; + let ops = app + .call("crews.create", json!({ "name": "Ops" })) + .await + .expect("ops"); + app.call("crews.create", json!({ "name": "Blog" })) + .await + .expect("blog"); + let bot = json!({ "crewId": ops["id"], "name": "Scout", "role": "", "instructions": "" }); + app.call("bots.create", bot).await.expect("bot"); + + let settings = t + .paths + .bot_workspace("ops", "scout") + .join(".claude") + .join("settings.json"); + let settings: Value = + serde_json::from_str(&std::fs::read_to_string(settings).expect("settings")).expect("json"); + let deny: Vec<&str> = settings["permissions"]["deny"] + .as_array() + .expect("deny") + .iter() + .filter_map(Value::as_str) + .collect(); + let rule = |path: &std::path::Path| botloftd::paths::permission_rule_path(path); + let blog = rule(&t.paths.crew_dir("blog")); + assert!( + deny.contains(&format!("Read({blog}/**)").as_str()), + "{deny:?}" + ); + assert!( + deny.contains(&format!("Edit({blog}/**)").as_str()), + "{deny:?}" + ); + let ops = rule(&t.paths.crew_dir("ops")); + assert!(!deny.iter().any(|rule| rule.contains(&ops)), "{deny:?}"); + let home = rule(&t.paths.home); + assert!( + deny.iter() + .any(|rule| rule.starts_with(&format!("Read({home}"))), + "{deny:?}" + ); +} diff --git a/docs/spec.md b/docs/spec.md index f2c2e283..23371588 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -115,7 +115,7 @@ O instalador por usuário do Tauri põe o app em `%LOCALAPPDATA%\`, a m Workspaces ficam num caminho curto e visível para o usuário abrir no Explorer e para reduzir estouro do limite de 260 caracteres (bots rodam `npm install`). -**Pasta de trabalho da crew.** É onde os bots põem o que fazem para o dono e para os outros bots. Por padrão é a `shared\` da crew; o dono pode escolher outra ao criar a crew ou depois (`crews.setWorkFolder`), como uma pasta de projeto que ele já usa. O workspace de cada bot continua onde está, com a memória, as regras e os anexos dele: o daemon nunca grava nada na pasta escolhida. Cada bot recebe a pasta com `--add-dir` (7.4), e o Claude Code a trata como a pasta do bot: lê e, em "Aceitar edições", edita sem perguntar, e carrega o `CLAUDE.md` dela junto com a memória do bot. Só o da própria pasta (`CLAUDE.md`, `.claude\CLAUDE.md`, `.claude\rules\` e `CLAUDE.local.md`): numa pasta escolhida dentro de um projeto, os `CLAUDE.md` das pastas acima dela não são carregados pelo Claude Code (19). A pasta escolhida precisa ser um caminho completo e não pode ser um disco inteiro, ficar dentro da pasta de dados do Botloft ou contê-la, nem conter `workspaces_root` (com os workspaces de todos os bots). Se não existe, é criada. Trocar a pasta regrava as regras e reinicia cada bot da crew quando nada estiver em andamento, como a troca de modo (7.4). Arquivar a crew não apaga pasta nenhuma. +**Pasta de trabalho da crew.** É onde os bots põem o que fazem para o dono e para os outros bots. Por padrão é a `shared\` da crew; o dono pode escolher outra ao criar a crew ou depois (`crews.setWorkFolder`), como uma pasta de projeto que ele já usa. O workspace de cada bot continua onde está, com a memória, as regras e os anexos dele: o daemon nunca grava nada na pasta escolhida. Cada bot recebe a pasta com `--add-dir` (7.4), e o Claude Code a trata como a pasta do bot: lê e, em "Aceitar edições", edita sem perguntar, e carrega o `CLAUDE.md` dela junto com a memória do bot. Só o da própria pasta (`CLAUDE.md`, `.claude\CLAUDE.md`, `.claude\rules\` e `CLAUDE.local.md`): numa pasta escolhida dentro de um projeto, os `CLAUDE.md` das pastas acima dela não são carregados pelo Claude Code (19). A pasta escolhida precisa ser um caminho completo e não pode ser um disco inteiro, ficar dentro da pasta de dados do Botloft ou contê-la, nem conter `workspaces_root` (com os workspaces de todos os bots). Também não pode encostar nas pastas de outra crew, arquivadas inclusive: nem ficar dentro da pasta de outra crew ou da pasta de trabalho que outra crew escolheu, nem contê-las. Dentro de `workspaces_root`, só na pasta da própria crew. Uma crew não enxerga a outra (7.5). Se não existe, é criada. Trocar a pasta regrava as regras e reinicia cada bot da crew quando nada estiver em andamento, como a troca de modo (7.4). Arquivar a crew não apaga pasta nenhuma. **Excluir não apaga pastas.** Excluir um bot ou uma crew (7.6) tira do Botloft e, a menos que o dono peça a Lixeira na confirmação (abaixo), deixa no disco o workspace de cada bot (memória, regras, anexos e o que ele fez), a `shared\` e a pasta que o dono escolheu: a confirmação do app diz que elas ficam e mostra o caminho (15.1), e o dono as apaga no Explorer se quiser. Do disco só sai o perfil do navegador do bot (21.2), que fica na pasta de dados e não tem nada que o dono tenha feito. Como a pasta fica, o slug dela continua ocupado, e um bot novo com o mesmo nome ganha sufixo (`revisor-2`). A conversa que o Claude Code guarda por conta própria (`%USERPROFILE%\.claude\projects\`) é dele e também fica. Se o dono marca a caixa da confirmação, a pasta que o Botloft criou vai para a Lixeira do Windows, de onde ainda dá para recuperar: o workspace do bot, ou a pasta inteira da crew (`\\`, com a `shared\` e os workspaces). A pasta de trabalho que o dono escolheu nunca é movida. @@ -268,7 +268,13 @@ Eventos de uma generation antiga são ignorados. Todo processo novo emite `bot.s ```json { "permissions": { - "deny": ["Read(//c/Users//AppData/Local/Botloft/secrets/**)"] + "deny": [ + "Read(//c/Users//AppData/Local/Botloft/secrets/**)", + "Read(//c/Users//AppData/Local/Botloft/**)", + "Edit(//c/Users//AppData/Local/Botloft/**)", + "Read(//c/Users//Botloft/outra-crew/**)", + "Edit(//c/Users//Botloft/outra-crew/**)" + ] }, "claudeMdExcludes": [ "C:/Users//Botloft/CLAUDE.md", @@ -286,7 +292,8 @@ Eventos de uma generation antiga são ignorados. Todo processo novo emite `bot.s (A lista de verdade repete os seis nomes para cada pasta acima da pasta da crew: `C:/Users//Botloft`, `C:/Users/`, `C:/Users` e `C:/`.) -- Regras `deny` valem mesmo sem confiança na pasta: elas só restringem. +- Regras `deny` valem mesmo sem confiança na pasta e em todo modo, inclusive `bypass_permissions`: elas só restringem. +- **Cercas** (`workspace::fences`): além dos segredos, `Read` e `Edit` (que cobre `Write`) negam a pasta de dados do Botloft inteira (banco com as conversas de todas as crews, perfis dos navegadores dos bots, logs) e as pastas das outras crews, arquivadas inclusive: a pasta da crew e a pasta de trabalho escolhida. Uma pasta que contém uma das da própria crew fica de fora, porque o `deny` venceria o acesso dela. Se `workspaces_root` estiver dentro da pasta de dados, só `browsers`, `logs`, `run` e `local-backup` são negados. O arquivo é regravado a cada início do bot: uma crew criada depois entra na cerca no próximo início. As regras de leitura do Claude Code cobrem as ferramentas de arquivo e alguns comandos simples, não um script qualquer pelo terminal (13). - Caminho absoluto em permission rule usa o prefixo `//` e a forma POSIX que o Claude Code aplica no Windows: `C:\Users\ana\...` vira `//c/Users/ana/...` (letra do drive em minúscula). Uma barra só (`/caminho`) é relativa à origem do settings, não à raiz, e não protegeria nada. O daemon converte `BOTLOFT_HOME` para essa forma ao gerar o arquivo. - Em `-p` o diálogo de confiança da pasta nunca aparece (documentado), então bot novo começa a trabalhar sem passo manual. - **Instruções de fora ficam de fora (`claudeMdExcludes`).** O Claude Code carrega `CLAUDE.md`, `CLAUDE.local.md`, `.claude/CLAUDE.md` e `.claude/rules/` do cwd e de toda pasta acima dele, como instruções de projeto, e `--setting-sources` não muda isso. O workspace fica dentro de `%USERPROFILE%` por padrão, então o `%USERPROFILE%\.claude\CLAUDE.md` do dono, que são as instruções pessoais dele para o Claude Code, chegava a todo bot; com `workspaces_root` dentro de um projeto (um daemon de dev), chegava o `CLAUDE.md` do projeto. O daemon lista, para cada pasta acima da pasta da crew, de `workspaces_root` até a raiz do disco, os seis nomes que o Claude Code procura: `CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md`, `.claude/CLAUDE.md`, `.claude/AGENTS.md` e `.claude/rules/**`. São caminhos exatos, sem `**` no começo: nenhum padrão alcança outra pasta. @@ -366,7 +373,7 @@ Todo item tem `id` (`cht_`), `botId`, `kind`, `createdAt` e `updatedAt`. O app mostra os arquivos que o bot fez num painel ao lado do chat (15.1). O daemon os acha de duas maneiras, porque um script ou comando (um PDF gerado por um `PowerShell`, por exemplo) não diz o que criou: - **Pastas:** a pasta de trabalho da crew e a pasta do bot (5), percorridas com limites: até 6 níveis, 20 mil entradas vistas, sem links, sem entradas ocultas (`.`, `~$`) nem pastas geradas (`node_modules`, `target`, `__pycache__`, `venv`, `site-packages`). Na pasta do bot ficam de fora `CLAUDE.md` e `attachments\` (o que o daemon escreve e o que o dono mandou). Só entra arquivo alterado **depois de o bot ser criado**: o que já existia na pasta escolhida pelo dono não é do bot. -- **Chamadas do bot:** os caminhos dos itens `tool` com `file` (8.2), mesmo fora dessas pastas, se o arquivo ainda existe. Esses vêm com `writtenByBot`. +- **Chamadas do bot:** os caminhos dos itens `tool` com `file` (8.2), mesmo fora dessas pastas, se o arquivo ainda existe e não está atrás das cercas de 7.5 (dados do Botloft, pastas de outras crews). Esses vêm com `writtenByBot`. `files.list {botId}` devolve até 200 `BotFile` (`path`, `name`, `folder` relativa à pasta de trabalho ou à do bot, `mediaType` pela extensão, `size`, `modifiedAt`, `writtenByBot`), do mais novo para o mais antigo. `files.read {botId, path}` devolve `{mediaType, data}` (base64) de um arquivo que a lista poderia mostrar: dentro de uma das duas pastas ou escrito pelo bot. Qualquer outro caminho, relativo ou que suba de pasta dá `not_found`, para o painel não ler o resto do computador. Acima de 20 MiB dá `validation`. Nenhum caminho nem conteúdo vai para o log em `info` ou acima (8.5). @@ -734,7 +741,8 @@ Pragmas: `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_time - Nome de anexo vira só o nome do arquivo (sem `..`, sem pasta, sem caracteres proibidos no Windows) antes de ir para o disco. - Isolamento entre bots é cooperativo (mesmo usuário do Windows). Documentar isso no README sem prometer sandbox. - Navegador dos bots (21): perfil próprio por bot, sem os logins do navegador do dono. A porta do DevTools escuta só em 127.0.0.1 e não tem senha: enquanto o navegador está aberto, outro programa do computador pode controlá-lo, como pode fazer com o resto do que roda com o usuário. Páginas não chegam a ela: o Chromium recusa WebSocket com `Origin` de site sem `--remote-allow-origins`. Trocar a porta por um pipe fica para depois. O texto das páginas pode trazer instruções para o bot; as tools dizem a ele que não valem como pedido do dono, e nos modos que perguntam, cada site novo passa pelo dono (21.5). -- Pasta de trabalho escolhida (5): todos os bots da crew a editam como a própria pasta. Por isso ela não pode tocar a pasta de dados do Botloft (segredos, banco) nem conter os workspaces de todos os bots, e não pode ser um disco inteiro. +- Pasta de trabalho escolhida (5): todos os bots da crew a editam como a própria pasta. Por isso ela não pode tocar a pasta de dados do Botloft (segredos, banco), conter os workspaces de todos os bots, encostar nas pastas de outra crew nem ser um disco inteiro. +- Crews separadas: as tools do MCP só alcançam a própria crew (10), e as cercas de 7.5 tiram das ferramentas de arquivo do bot a pasta de dados e as pastas das outras crews. Um comando qualquer no terminal ainda pode ler fora delas; isolar de verdade pede uma sandbox do sistema, que fica para depois. - Modo `bypass_permissions` (7.4): o bot faz tudo sem perguntar. As regras `deny` de leitura só cobrem as ferramentas de arquivo do Claude Code, alguns comandos do Bash (`cat`, `head`, `tail`, `sed`, `tee`) e redirecionamentos, não um script em Python ou Node, e o Windows nativo não tem sandbox. Um bot nesse modo pode ler `secrets\owner.token`, o banco e as pastas de outros bots, e uma mensagem de outra pessoa pode levá-lo a isso. O app só liga o modo depois de uma confirmação que diz isso, e o bot fica marcado em vermelho (15.1). Um chefe nesse modo também cria bots sem perguntar (10.2), e a confirmação diz isso quando o bot é o chefe. ## 14. Integração com o Windows @@ -1087,6 +1095,7 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28; esf | Expansão `${VAR}` em headers do `mcp.json` | 10 | Confirmado (`mcp`); alguns nomes de credencial conhecidos são lidos vazios, `BOTLOFT_BOT_TOKEN` não é um deles. **Testado com 2.1.284**: o header chegou com o valor da variável de ambiente | feito (M3) | | Revisão do MCP que o Claude Code usa | 10 | Especificação MCP 2026-07-28 (sem `initialize`) e versões antigas. **Visto com 2.1.284**: manda `server/discover` com os headers de 2026-07-28 e, se falhar, `initialize` com 2025-11-25; o mesmo num servidor stdio | feito (M3) | | Sintaxe de caminho Windows em permission rules | 7.5 | Confirmado (`permissions`) e **testado com 2.1.283** (interativo) e **2.1.284** (`-p --setting-sources project,local`): `Read(//c/.../**)` em `deny` bloqueou a leitura com "File is in a directory that is denied by your permission settings", sem perguntar, e o `result` listou a negação em `permission_denials` | feito (M4.1) | +| Cercas de `Read` e `Edit` em `bypass_permissions` | 7.5 | **Testado com 2.1.287** (`-p --setting-sources project,local --permission-mode bypassPermissions`, Haiku): com `Read(//c/.../other/**)` e `Edit(//c/.../other/**)` em `deny` e a mesma pasta em `--add-dir`, o `Read` e o `Write` nela falharam com "File is in a directory that is denied by your permission settings", sem perguntar; os dois apareceram em `permission_denials` e o arquivo não foi criado | feito | | Edge sem janela controlado por CDP | 21.2, 21.3 | **Visto com o Edge 154** (Windows 11): `--headless=new --remote-debugging-port=0` escreve `DevToolsActivePort` em ~400 ms; `Target.setAutoAttach` no alvo do navegador prende a aba que já existe e cada popup (`openerId`, esperando o depurador); `Emulation.setDeviceMetricsOverride` deixa a tela em 1280 × 800, e chamado de novo com a página aberta muda o `innerHeight` dela na hora; `Page.startScreencast` manda quadros JPEG com o tamanho da tela, também depois dessa mudança (com `maxHeight` no limite de 21.3), mas o quadro do tamanho novo se perde quando há quadros anteriores ainda sem `screencastFrameAck`, e uma página parada não manda outro: o daemon espera esses serem confirmados e recomeça o screencast (`stopScreencast`, `startScreencast`), que então manda um quadro; clique por `Input.dispatchMouseEvent` segue links; `alert` chega em `Page.javascriptDialogOpening`. `Target.targetInfoChanged` chega com o endereço novo da aba, mas com o endereço no lugar do título, e não vem de novo quando a página ganha o título. Ler o título com `Target.getTargetInfo` depois do `Page.loadEventFired` falhou de vez em quando nos testes (ficava o endereço); o `document.title` lido na página, num mundo isolado, não falhou. `Target.createTarget` com `about:blank` abre uma aba que chega por `attachedToTarget` como as outras; `Target.activateTarget` numa aba que ficou atrás a traz de volta, e o screencast dela volta a mandar quadros. Um Edge parado numa página que se mexe (um timer ocupado e uma animação) gastou 84% de um núcleo sem ninguém assistindo e 126% com o screencast, em ~450 MB e 7 a 10 processos. `Browser.setWindowBounds` com `windowState: "minimized"` na janela do `Browser.getWindowForTarget` deixou a página `hidden`, com o timer a menos de um por segundo e o processador abaixo de 1%; `normal` a trouxe de volta inteira, e `Page.captureScreenshot` funcionou em seguida. `Page.setWebLifecycleState` com `frozen` também parou a página, mas `active` a deixou `hidden` e lenta, e um `Page.bringToFront` depois disso travou o `Runtime.evaluate`: não serve. `Emulation.setCPUThrottlingRate` gastou mais, não menos. O user agent traz `HeadlessChrome`. Num popup esperando o depurador, `Page.enable` não responde até `Runtime.runIfWaitingForDebugger`; um mundo isolado com o mesmo nome (`Page.createIsolatedWorld`) volta o mesmo contexto até a página navegar, não enxerga os globais da página e lê `iframe` do mesmo site | feito Nitidez (visto com o Edge do Windows 11, outubro de 2026): com `deviceScaleFactor` 2 a página diz `devicePixelRatio` 2, mas os quadros do `Page.startScreencast` continuam nos pixels da página (1000 × 700 para uma página de 1000 × 700), com `maxWidth` folgado e até com o `scale` do `setDeviceMetricsOverride`; o `Page.captureScreenshot` vem com os pixels da tela (2000 × 1400). Com `clip` (`x`/`y` de `cssVisualViewport.pageX/pageY`, o tamanho da página e `scale` 0,5), ele volta a 1000 × 700 e mostra a parte para onde a página está rolada. | | O que um site vê do Edge sem janela | 21.3 | **Visto com o Edge 154** (Windows 11), com uma página que mostra os sinais: sem nada, `navigator.webdriver` é `true`, o user agent traz `HeadlessChrome` e `outerWidth` e `outerHeight` são 0. `Emulation.setUserAgentOverride` só com `userAgent` deixa `navigator.userAgentData.brands` vazio; com `userAgentMetadata` (o que `getHighEntropyValues` devolve, com os mesmos nomes), as marcas voltam. `about:blank` não tem `navigator.userAgentData` (não é página segura); `chrome://version` tem. `--user-agent` na linha de comando mantém as marcas, mas esvazia `fullVersionList`, `architecture` e `platformVersion`. `--disable-blink-features=AutomationControlled` deixa `navigator.webdriver` `false` mesmo com `--remote-debugging-port`. `--window-size` deixa a janela com o tamanho pedido (`Browser.getWindowForTarget`), mas `outerWidth` continua 0. O login do Google recusa o navegador ("Esse navegador ou app pode não ser seguro"), também com o dono no controle | feito | | Login numa janela de verdade | 21.11 | **Visto com o Edge 154** (Windows 11): `msedge.exe` com `--user-data-dir` e sem `--remote-debugging-port` fica vivo como o próprio navegador e sai alguns segundos depois de a janela fechar. Um cookie gravado nessa janela aparece no Edge sem janela aberto depois no mesmo perfil. **A verificar** com o Claude Code real: o dono entra numa conta do Google pela janela, fecha, e o bot abre o Gmail já dentro da conta; o bot que pediu ajuda lê que o navegador fechou e abre a página de novo | manual (PR) | @@ -1346,7 +1355,7 @@ O navegador respeita o modo do bot (7.4), como o Claude Code faz com o `WebFetch - **Site** é o host em minúsculas, sem `www.`. Permitir `wikipedia.org` vale também para `pt.wikipedia.org`; o contrário não. - **Quando pergunta:** `browser_open` pergunta pelo site do endereço; as outras tools, pelo site da página ativa. Um clique que leva a outro site mostra a página nova, mas a próxima ação nela pergunta. `about:blank` não tem site. - **Arquivos do bot:** `file:` só dentro da pasta do bot ou da pasta de trabalho da crew (5), e sem perguntar: são os arquivos que ele mesmo fez. Qualquer outro `file:` e os esquemas `data:`, `javascript:`, `edge:`, `chrome:` e `about:` (fora `about:blank`) são recusados. -- `localhost` e `127.0.0.1` são sites como os outros: um servidor de desenvolvimento que o bot sobe pergunta uma vez. +- `localhost` e `127.0.0.1` são sites como os outros: um servidor de desenvolvimento que o bot sobe pergunta uma vez. A exceção é a porta do DevTools de qualquer navegador de bot (a de cada perfil, lida do `DevToolsActivePort`): em qualquer modo, abrir um endereço local nessa porta é recusado, e uma página que chegue lá é fechada. Sem isso, um bot leria as abas do navegador de um bot de outra crew. ### 21.6 O que o bot lê