diff --git a/docs/architecture.md b/docs/architecture.md index 4a09cdc..0255e68 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -71,7 +71,7 @@ AnyBot 是一個 Rust 程式(`anybot`),用 egui/eframe 畫介面。同一 ## 安全 - **核准關卡**:工具動作照規則直接做、先問或拒絕(`policy.rs`,FEATURES.md 第 16、19 節)。「直接操作模式」可以跳過詢問。 -- **指令的沙盒(macOS)**:Bot 執行的指令在 `sandbox-exec` 裡跑,讀不到也寫不到 AnyBot 的資料資料夾和 `.ssh`、`.gnupg`、鑰匙圈、`.aws` 等;只有 `attachments/` 可以讀(不能寫)。環境變數裡的模型金鑰會先拿掉。 +- **指令的沙盒(macOS)**:Bot 執行的指令在 `sandbox-exec` 裡跑,讀不到也寫不到 AnyBot 的資料資料夾、`.gnupg`、鑰匙圈、`.aws` 等;只有 `attachments/` 和 `.ssh` 可以讀(都不能寫,`.ssh` 可讀讓 git 和 ssh 連得動)。只有沙盒管得到的才是祕密防線,核准層不再逐字掃路徑。環境變數裡的模型金鑰會先拿掉,PATH 會補齊 Homebrew 等位置(從 Finder 或 launchd 啟動也要找得到指令)。 - **Bot 的電腦**:放在容器裡,和你的電腦分開。 - **保險庫**:密碼、卡號、CVC 不會交給模型,由 AnyBot 直接填進網頁。 - **多台電腦**:金鑰只留在大腦(下面說明);手腳只接受帳號裡的 AnyBot,用配對碼加入的只接受那一個大腦。 @@ -115,7 +115,7 @@ AnyBot 是一個 Rust 程式(`anybot`),用 egui/eframe 畫介面。同一 ### 手腳 -大腦的 Bot 呼叫工具時可以指定 `computer`。如果是帳號裡的另一台,就透過長連線送 `/api/device/run` 給它,由它在自己身上執行(螢幕、檔案、指令、它的 AnyBot 瀏覽器),再把文字和截圖傳回來。那台要開「裝置連結」才接受;若設了只給某個大腦用(`hands_for`),帳號伺服器會附上是哪一台在問,不是那台就拒絕。 +大腦的 Bot 呼叫工具時可以指定 `computer`。如果是帳號裡的另一台,就透過長連線送 `/api/device/run` 給它,由它在自己身上執行(螢幕、檔案、指令、它的 AnyBot 瀏覽器),再把文字和截圖傳回來。那台要開「裝置連結」才接受;若設了只給某個大腦用(`hands_for`),帳號伺服器會附上是哪一台在問,不是那台就拒絕。指令在那台的工作資料夾(Bot 的 `workdir`)跑;連線經過帳號伺服器只有 35 秒,所以手腳上的指令超過 25 秒就移到背景,用同一台的 `read_terminal` 追蹤(computer 填那台)。 ### 配對碼 diff --git a/src/agent.rs b/src/agent.rs index 06b7572..122d8b5 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -131,7 +131,7 @@ fn browser_place(job: &Job, name: &str, args: &str) -> (BrowserAt, String) { let want = v.get("computer").and_then(|c| c.as_str()).map(str::trim).filter(|c| !c.is_empty()).unwrap_or(&job.default_computer).to_string(); let at = |t: &tools::Target| -> Option { match &t.place { - tools::Place::Device { host } => Some(BrowserAt::Device(host.clone())), + tools::Place::Device { host, .. } => Some(BrowserAt::Device(host.clone())), tools::Place::Box { .. } if crate::cloudbox::available() => Some(BrowserAt::Box), tools::Place::Local { .. } if crate::botbrowser::available() => Some(BrowserAt::Here), _ => None, @@ -262,7 +262,7 @@ fn roles_now(job: &Job) -> tools::Roles { let mut r = job.roles.clone(); let host = crate::remote::person_host(); if !host.is_empty() { - if let Some(t) = job.computers.iter().find(|t| matches!(&t.place, tools::Place::Device { host: h } if *h == host)) { + if let Some(t) = job.computers.iter().find(|t| matches!(&t.place, tools::Place::Device { host: h, .. } if *h == host)) { r.person = Some(t.name.clone()); } } @@ -651,7 +651,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< // computer the person is using; a file is sent into the chat instead. let person_now = roles_now(&job).person; let person_host = person_now.as_ref().and_then(|n| job.computers.iter().find(|t| &t.name == n)).and_then(|t| match &t.place { - tools::Place::Device { host } => Some(host.clone()), + tools::Place::Device { host, .. } => Some(host.clone()), _ => None, }); if job.roles.server { @@ -887,7 +887,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< }; let on_box = matches!(target.as_ref().map(|t| &t.place), Ok(tools::Place::Box { .. })); let on_device = match target.as_ref().map(|t| &t.place) { - Ok(tools::Place::Device { host }) => Some(host.clone()), + Ok(tools::Place::Device { host, .. }) => Some(host.clone()), _ => None, }; // Permission to use computers at all, and this Mac in particular, is asked for @@ -1151,7 +1151,7 @@ mod tests { assert!(!finds("你的虛擬電腦很吵", "bot 的電腦")); assert!(finds("在 bot 的電腦跑", "bot 的電腦")); let roles = tools::Roles { own: "Master 的電腦".into(), person: Some("王小明的Mac mini".into()), server: true }; - let mac = tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into() } }; + let mac = tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into(), dir: String::new() } }; assert!(mentions("幫我在 Mac 截圖", &mac, &roles)); assert!(!mentions("在 macOS 的登入機制下,幫 Bot 的電腦截圖", &mac, &roles)); } @@ -1171,8 +1171,8 @@ mod tests { // the everyday words point at the right one (「我的電腦」 = where the person typed). let roles = tools::Roles { own: "Master 的電腦".into(), person: Some("王小明的Mac mini".into()), server: true }; let own = tools::Target { name: "Master 的電腦".into(), place: tools::Place::Box { dir: String::new() } }; - let mac = tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into() } }; - let desk = tools::Target { name: "ai".into(), place: tools::Place::Device { host: "a".into() } }; + let mac = tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into(), dir: String::new() } }; + let desk = tools::Target { name: "ai".into(), place: tools::Place::Device { host: "a".into(), dir: String::new() } }; let server = tools::Target { name: "雲端大腦".into(), place: tools::Place::Local { workdir: Default::default() } }; assert!(mentions("在 Master 的電腦打開 Threads", &own, &roles)); assert!(mentions("用master的電腦", &own, &roles)); @@ -1192,7 +1192,7 @@ mod tests { let job = Job { computers: vec![ tools::Target { name: "Master 的電腦".into(), place: tools::Place::Box { dir: String::new() } }, - tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into() } }, + tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into(), dir: String::new() } }, ], default_computer: "Master 的電腦".into(), roles, @@ -1491,7 +1491,7 @@ mod tests { }); let job = Job { req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("幫我登入 Threads")], tools: vec![], session: String::new() }, - computers: vec![tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into() } }], + computers: vec![tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into(), dir: String::new() } }], default_computer: "王小明的Mac mini".into(), roles: tools::Roles { own: "Master 的電腦".into(), person: Some("王小明的Mac mini".into()), server: true }, ..test_job() diff --git a/src/app.rs b/src/app.rs index eb9583d..e3aada1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -870,8 +870,12 @@ impl App { for c in &self.state.remote_computers { add(&mut v, tools::Target { name: c.name.clone(), place: tools::Place::Ssh { target: c.ssh_target.clone(), dir: c.dir.clone() } }); } - // 裝置連結: other AnyBots of this account that allow it. + // 裝置連結: other AnyBots of this account that allow it, working in the bot's work folder. for d in self.linked_devices() { + let mut d = d; + if let tools::Place::Device { dir, .. } = &mut d.place { + dir.clone_from(&b.workdir); + } add(&mut v, d); } v @@ -883,7 +887,7 @@ impl App { pub(super) fn roles_for(&self, b: &Bot, targets: &[tools::Target]) -> tools::Roles { let server = self.headless; let local = targets.iter().find(|t| matches!(t.place, tools::Place::Local { .. })).map(|t| t.name.clone()); - let device = |h: &str| targets.iter().find(|t| matches!(&t.place, tools::Place::Device { host } if host == h)).map(|t| t.name.clone()); + let device = |h: &str| targets.iter().find(|t| matches!(&t.place, tools::Place::Device { host, .. } if host == h)).map(|t| t.name.clone()); let person = match self.typed_on.as_deref() { Some(h) if !h.is_empty() => device(h).or_else(|| if server { None } else { local.clone() }), _ if !server => local, diff --git a/src/app/remote_ui.rs b/src/app/remote_ui.rs index c0668cc..929e1c1 100644 --- a/src/app/remote_ui.rs +++ b/src/app/remote_ui.rs @@ -776,7 +776,7 @@ impl App { let uses = remote::peer_uses(id); uses.is_empty() || uses == self.state.remote.host_id }) - .map(|(id, name, _)| tools::Target { name: name.clone(), place: tools::Place::Device { host: id.clone() } }) + .map(|(id, name, _)| tools::Target { name: name.clone(), place: tools::Place::Device { host: id.clone(), dir: String::new() } }) .collect() } } diff --git a/src/remote.rs b/src/remote.rs index 3b2e8b6..d90bd4a 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -708,7 +708,13 @@ fn device_run(v: &Value, s: &Settings, via_relay: bool, from: &str) -> Resp { return Resp::json(&json!({"text": text, "ok": ok})); } if matches!(tool, "shell" | "read_file" | "edit_file" | "list_dir" | "search" | "read_terminal" | "write_terminal") { - let t = crate::tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: crate::tools::Place::Local { workdir: crate::tools::expand_home(v["workdir"].as_str().unwrap_or("~")) } }; + // The brain passes the bot's work folder inside the tool's args (empty = this home). + let dir = crate::tools::args_of(args).get("workdir").and_then(Value::as_str).unwrap_or("").trim().to_string(); + let mut workdir = crate::tools::expand_home(if dir.is_empty() { "~" } else { &dir }); + if !workdir.is_absolute() { + workdir = dirs::home_dir().unwrap_or(workdir); + } + let t = crate::tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: crate::tools::Place::Local { workdir } }; let ids = crate::tools::RunIds { conversation: "device".into(), request: httpd::random_hex(6) }; let (text, ok) = crate::tools::run_on(tool, args, &t, &std::sync::atomic::AtomicBool::new(false), &ids); return Resp::json(&json!({"text": text, "ok": ok})); diff --git a/src/tools.rs b/src/tools.rs index 2550aab..c53b4f9 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -26,6 +26,9 @@ pub const READ_DEFAULT_LINES: usize = 2_000; /// Foreground time before a command is moved to the background (estimated; the original's comes per call). pub const SHELL_DEFAULT_TIMEOUT_MS: u64 = 30_000; pub const SHELL_MAX_TIMEOUT_MS: u64 = 600_000; +/// A command on a relayed computer must answer before the relay's forward timeout (35 s): +/// on the device it moves to the background by this deadline, and read_terminal picks it up. +pub const DEVICE_SHELL_TIMEOUT_MS: u64 = 25_000; /// Output kept from a command: its start and its end, with the middle left out (sizes estimated; /// the original reports output_head / output_tail / elided_chars). pub const OUTPUT_HEAD_CHARS: usize = 6_000; @@ -49,7 +52,7 @@ pub fn specs() -> Vec { json!({"name": "edit_file", "description": "Change a file. Replaces old_text (which must appear exactly once) with new_text. With empty old_text the file is created or overwritten with new_text.", "parameters": {"type": "object", "properties": { "path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "new_text"]}}), - json!({"name": "shell", "description": "Run a shell command in the work folder. If it runs longer than timeout_ms it keeps running in the background and you get a terminal id to check with read_terminal.", + json!({"name": "shell", "description": "Run a shell command in the work folder. If it runs longer than timeout_ms it keeps running in the background and you get a terminal id to check with read_terminal (pass the same computer to read_terminal/write_terminal).", "parameters": {"type": "object", "properties": { "command": {"type": "string"}, "timeout_ms": {"type": "integer", "description": "How long to wait before moving it to the background (default 30000)."}}, "required": ["command"]}}), @@ -330,7 +333,17 @@ pub fn decide(name: &str, args: &str, workdir: &Path, grants: &[String]) -> Deci return Decision::Deny("沒有指令".into()); } let Some(parts) = split_commands(&cmd) else { - return Decision::Ask { reason: format!("執行指令(含有隱藏的子指令,無法自動判斷):{cmd}"), grant: String::new() }; + // Commands the splitter can't read ($(), backticks, eval…) ask once and remember + // by their first word: 「一律允許」 on `git` covers `git … $(…)` afterwards, so + // the same shape of command doesn't ask again (models use $(…) constantly). + if let Some(d) = denied_hit(&cmd) { + return Decision::Deny(format!("「{d}」這類指令一律不允許。")); + } + let grant = shell_grant(&cmd); + if !writes_files(&cmd) && granted(&grant) { + return Decision::Allow; + } + return Decision::Ask { reason: format!("執行指令(含有隱藏的子指令,無法自動判斷):{cmd}"), grant }; }; // deny for part in &parts { @@ -338,14 +351,8 @@ pub fn decide(name: &str, args: &str, workdir: &Path, grants: &[String]) -> Deci return Decision::Deny(format!("「{d}」這類指令一律不允許。")); } } - // ignore: commands that point at secret files - for part in &parts { - for w in words(part).iter().skip(1) { - if is_ignored(&resolve(workdir, w.trim_matches(['"', '\'']))) { - return Decision::Deny(format!("指令碰到私密或系統檔案:{w}")); - } - } - } + // (No word-by-word path scan here: secret folders are the sandbox's job, and + // scanning words as paths denied perfectly ordinary commands.) // allow: every part is read-only or already granted let ok = |part: &String| !writes_files(part) && (READ_ONLY_COMMANDS.iter().any(|r| starts_with_words(part, r)) || granted(&shell_grant(part))); let unapproved: Vec<&String> = parts.iter().filter(|p| !ok(p)).collect(); @@ -466,9 +473,14 @@ pub fn protected_dirs() -> Vec { } /// Inside the protected folders, what commands may still read: the chat's attachments (a picture -/// the person sent is the bot's to look at; the keys next to it are not). +/// the person sent is the bot's to look at; the keys next to it are not) and the ssh keys, so +/// git and ssh can reach remote repos (writing them stays denied). pub fn readable_dirs() -> Vec { - crate::model::State::path().parent().map(|d| vec![d.join("attachments")]).unwrap_or_default() + let mut v: Vec = crate::model::State::path().parent().map(|d| vec![d.join("attachments")]).unwrap_or_default(); + if let Some(h) = dirs::home_dir() { + v.push(h.join(".ssh")); + } + v } fn sandbox_exec() -> Option<&'static str> { @@ -525,6 +537,9 @@ pub fn build_command_sealed(cmd: &str, workdir: &Path, protected: &[PathBuf], re for k in KEY_ENV_VARS { c.env_remove(k); } + // The app started from Finder or launchd has no shell PATH (no /opt/homebrew/bin): a + // command run on an always-on Mac (a Mac mini under LaunchAgent) must find its tools too. + c.env("PATH", crate::cloudbox::tool_path()); c.env(ENV_CONVERSATION_ID, conversation).env(ENV_REQUEST_ID, request); #[cfg(unix)] { @@ -616,8 +631,9 @@ pub enum Place { Local { workdir: PathBuf }, /// Another computer: `ssh target`, starting in `dir` (empty = its home). Ssh { target: String, dir: String }, - /// 裝置連結: another AnyBot of the same account (through the relay), by its host id. - Device { host: String }, + /// 裝置連結: another AnyBot of the same account (through the relay), by its host id, + /// starting in `dir` (empty = its home). + Device { host: String, dir: String }, } #[derive(Clone, Debug)] @@ -641,9 +657,8 @@ pub struct Roles { pub fn specs_for(computers: &[String], default: &str) -> Vec { let mut v = specs(); for t in v.iter_mut() { - if t["name"] == "read_terminal" || t["name"] == "write_terminal" { - continue; - } + // Every tool that works on a computer names one, background terminals included: a command + // moved to the background on the Mac mini is read on the Mac mini. // Each computer goes by its own name; the instructions say which is which. Working on // one and calling it another (the server as "your computer") is what confused people. let desc = format!("Which computer to work on, by its name. Default: {default} (the one the person chose). Use another only when the person names it; when unsure which one they mean, ask."); @@ -760,10 +775,30 @@ pub fn run_on(name: &str, args: &str, target: &Target, cancel: &AtomicBool, ids: }; match &target.place { Place::Local { workdir } => run(name, args, workdir, cancel, ids), - Place::Device { host } => match crate::remote::run_on_device(host, name, args, "") { - Ok(v) => (v["text"].as_str().unwrap_or("").to_string(), v["ok"].as_bool().unwrap_or(false)), - Err(e) => (e, false), - }, + Place::Device { host, dir } => { + // The device runs the tool in the bot's work folder (its own home when unset), and + // must answer before the relay gives up at 35 s: long commands move to the + // background there, and read_terminal now names this computer to find them. + let mut v = v.clone(); + let cap = timeout_of(&v).min(DEVICE_SHELL_TIMEOUT_MS); + if let Some(o) = v.as_object_mut() { + if !dir.is_empty() && o.get("workdir").and_then(Value::as_str).unwrap_or("").is_empty() { + o.insert("workdir".into(), serde_json::Value::String(dir.clone())); + } + o.insert("timeout_ms".into(), serde_json::Value::from(cap)); + } + let args = v.to_string(); + match crate::remote::run_on_device(host, name, &args, "") { + Ok(v) => { + let mut text = v["text"].as_str().unwrap_or("").to_string(); + if text.contains("read_terminal") { + text.push_str(&format!("(這台是「{}」:read_terminal 的 computer 填「{}」。)", target.name, target.name)); + } + (text, v["ok"].as_bool().unwrap_or(false)) + } + Err(e) => (e, false), + } + } Place::Sealed { root } => { let _ = std::fs::create_dir_all(root); if name == "shell" { @@ -1235,7 +1270,12 @@ mod tests { let w = wd(); assert_eq!(decide("shell", r#"{"command":"ls && git status"}"#, &w, &[]), Decision::Allow); assert!(matches!(decide("shell", r#"{"command":"ls && sudo rm x"}"#, &w, &[]), Decision::Deny(_))); - assert!(matches!(decide("shell", r#"{"command":"cat .env"}"#, &w, &[]), Decision::Deny(_))); + // Secrets are the sandbox's job; the permission layer no longer denies words that + // merely look like secret paths. Read-only commands run (cat .env is cat), and the + // sandbox hides the protected folders from them. + assert_eq!(decide("shell", r#"{"command":"cat .env"}"#, &w, &[]), Decision::Allow); + assert!(matches!(decide("shell", r#"{"command":"echo $(whoami)"}"#, &w, &[]), Decision::Ask { ref grant, .. } if grant == "shell:echo")); + assert_eq!(decide("shell", r#"{"command":"echo $(whoami)"}"#, &w, &["shell:echo".into()]), Decision::Allow); assert!(matches!(decide("shell", r#"{"command":"npm install"}"#, &w, &[]), Decision::Ask { ref grant, .. } if grant == "shell:npm install")); assert_eq!(decide("shell", r#"{"command":"npm install && ls"}"#, &w, &["shell:npm install".into()]), Decision::Allow); assert!(matches!(decide("shell", r#"{"command":"echo hi > out.txt"}"#, &w, &[]), Decision::Ask { .. }));