Commands on another computer run in the bot's work folder and finish before the relay gives up; read_terminal goes to the same computer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
db028b0b5b
commit
26fa2a8ee9
|
|
@ -71,7 +71,7 @@ AnyBot 是一個 Rust 程式(`anybot`),用 egui/eframe 畫介面。同一
|
||||||
## 安全
|
## 安全
|
||||||
|
|
||||||
- **核准關卡**:工具動作照規則直接做、先問或拒絕(`policy.rs`,FEATURES.md 第 16、19 節)。「直接操作模式」可以跳過詢問。
|
- **核准關卡**:工具動作照規則直接做、先問或拒絕(`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 的電腦**:放在容器裡,和你的電腦分開。
|
- **Bot 的電腦**:放在容器裡,和你的電腦分開。
|
||||||
- **保險庫**:密碼、卡號、CVC 不會交給模型,由 AnyBot 直接填進網頁。
|
- **保險庫**:密碼、卡號、CVC 不會交給模型,由 AnyBot 直接填進網頁。
|
||||||
- **多台電腦**:金鑰只留在大腦(下面說明);手腳只接受帳號裡的 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 填那台)。
|
||||||
|
|
||||||
### 配對碼
|
### 配對碼
|
||||||
|
|
||||||
|
|
|
||||||
18
src/agent.rs
18
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 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<BrowserAt> {
|
let at = |t: &tools::Target| -> Option<BrowserAt> {
|
||||||
match &t.place {
|
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::Box { .. } if crate::cloudbox::available() => Some(BrowserAt::Box),
|
||||||
tools::Place::Local { .. } if crate::botbrowser::available() => Some(BrowserAt::Here),
|
tools::Place::Local { .. } if crate::botbrowser::available() => Some(BrowserAt::Here),
|
||||||
_ => None,
|
_ => None,
|
||||||
|
|
@ -262,7 +262,7 @@ fn roles_now(job: &Job) -> tools::Roles {
|
||||||
let mut r = job.roles.clone();
|
let mut r = job.roles.clone();
|
||||||
let host = crate::remote::person_host();
|
let host = crate::remote::person_host();
|
||||||
if !host.is_empty() {
|
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());
|
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.
|
// computer the person is using; a file is sent into the chat instead.
|
||||||
let person_now = roles_now(&job).person;
|
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 {
|
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,
|
_ => None,
|
||||||
});
|
});
|
||||||
if job.roles.server {
|
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_box = matches!(target.as_ref().map(|t| &t.place), Ok(tools::Place::Box { .. }));
|
||||||
let on_device = match target.as_ref().map(|t| &t.place) {
|
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,
|
_ => None,
|
||||||
};
|
};
|
||||||
// Permission to use computers at all, and this Mac in particular, is asked for
|
// 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 的電腦"));
|
||||||
assert!(finds("在 bot 的電腦跑", "bot 的電腦"));
|
assert!(finds("在 bot 的電腦跑", "bot 的電腦"));
|
||||||
let roles = tools::Roles { own: "Master 的電腦".into(), person: Some("王小明的Mac mini".into()), server: true };
|
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("幫我在 Mac 截圖", &mac, &roles));
|
||||||
assert!(!mentions("在 macOS 的登入機制下,幫 Bot 的電腦截圖", &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).
|
// 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 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 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 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() } };
|
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() } };
|
let server = tools::Target { name: "雲端大腦".into(), place: tools::Place::Local { workdir: Default::default() } };
|
||||||
assert!(mentions("在 Master 的電腦打開 Threads", &own, &roles));
|
assert!(mentions("在 Master 的電腦打開 Threads", &own, &roles));
|
||||||
assert!(mentions("用master的電腦", &own, &roles));
|
assert!(mentions("用master的電腦", &own, &roles));
|
||||||
|
|
@ -1192,7 +1192,7 @@ mod tests {
|
||||||
let job = Job {
|
let job = Job {
|
||||||
computers: vec![
|
computers: vec![
|
||||||
tools::Target { name: "Master 的電腦".into(), place: tools::Place::Box { dir: String::new() } },
|
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(),
|
default_computer: "Master 的電腦".into(),
|
||||||
roles,
|
roles,
|
||||||
|
|
@ -1491,7 +1491,7 @@ mod tests {
|
||||||
});
|
});
|
||||||
let job = Job {
|
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() },
|
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(),
|
default_computer: "王小明的Mac mini".into(),
|
||||||
roles: tools::Roles { own: "Master 的電腦".into(), person: Some("王小明的Mac mini".into()), server: true },
|
roles: tools::Roles { own: "Master 的電腦".into(), person: Some("王小明的Mac mini".into()), server: true },
|
||||||
..test_job()
|
..test_job()
|
||||||
|
|
|
||||||
|
|
@ -870,8 +870,12 @@ impl App {
|
||||||
for c in &self.state.remote_computers {
|
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() } });
|
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() {
|
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);
|
add(&mut v, d);
|
||||||
}
|
}
|
||||||
v
|
v
|
||||||
|
|
@ -883,7 +887,7 @@ impl App {
|
||||||
pub(super) fn roles_for(&self, b: &Bot, targets: &[tools::Target]) -> tools::Roles {
|
pub(super) fn roles_for(&self, b: &Bot, targets: &[tools::Target]) -> tools::Roles {
|
||||||
let server = self.headless;
|
let server = self.headless;
|
||||||
let local = targets.iter().find(|t| matches!(t.place, tools::Place::Local { .. })).map(|t| t.name.clone());
|
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() {
|
let person = match self.typed_on.as_deref() {
|
||||||
Some(h) if !h.is_empty() => device(h).or_else(|| if server { None } else { local.clone() }),
|
Some(h) if !h.is_empty() => device(h).or_else(|| if server { None } else { local.clone() }),
|
||||||
_ if !server => local,
|
_ if !server => local,
|
||||||
|
|
|
||||||
|
|
@ -776,7 +776,7 @@ impl App {
|
||||||
let uses = remote::peer_uses(id);
|
let uses = remote::peer_uses(id);
|
||||||
uses.is_empty() || uses == self.state.remote.host_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()
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}));
|
return Resp::json(&json!({"text": text, "ok": ok}));
|
||||||
}
|
}
|
||||||
if matches!(tool, "shell" | "read_file" | "edit_file" | "list_dir" | "search" | "read_terminal" | "write_terminal") {
|
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 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);
|
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}));
|
return Resp::json(&json!({"text": text, "ok": ok}));
|
||||||
|
|
|
||||||
82
src/tools.rs
82
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).
|
/// 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_DEFAULT_TIMEOUT_MS: u64 = 30_000;
|
||||||
pub const SHELL_MAX_TIMEOUT_MS: u64 = 600_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;
|
/// 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).
|
/// the original reports output_head / output_tail / elided_chars).
|
||||||
pub const OUTPUT_HEAD_CHARS: usize = 6_000;
|
pub const OUTPUT_HEAD_CHARS: usize = 6_000;
|
||||||
|
|
@ -49,7 +52,7 @@ pub fn specs() -> Vec<Value> {
|
||||||
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.",
|
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": {
|
"parameters": {"type": "object", "properties": {
|
||||||
"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "new_text"]}}),
|
"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": {
|
"parameters": {"type": "object", "properties": {
|
||||||
"command": {"type": "string"},
|
"command": {"type": "string"},
|
||||||
"timeout_ms": {"type": "integer", "description": "How long to wait before moving it to the background (default 30000)."}}, "required": ["command"]}}),
|
"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());
|
return Decision::Deny("沒有指令".into());
|
||||||
}
|
}
|
||||||
let Some(parts) = split_commands(&cmd) else {
|
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
|
// deny
|
||||||
for part in &parts {
|
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}」這類指令一律不允許。"));
|
return Decision::Deny(format!("「{d}」這類指令一律不允許。"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ignore: commands that point at secret files
|
// (No word-by-word path scan here: secret folders are the sandbox's job, and
|
||||||
for part in &parts {
|
// scanning words as paths denied perfectly ordinary commands.)
|
||||||
for w in words(part).iter().skip(1) {
|
|
||||||
if is_ignored(&resolve(workdir, w.trim_matches(['"', '\'']))) {
|
|
||||||
return Decision::Deny(format!("指令碰到私密或系統檔案:{w}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// allow: every part is read-only or already granted
|
// 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 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();
|
let unapproved: Vec<&String> = parts.iter().filter(|p| !ok(p)).collect();
|
||||||
|
|
@ -466,9 +473,14 @@ pub fn protected_dirs() -> Vec<PathBuf> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inside the protected folders, what commands may still read: the chat's attachments (a picture
|
/// 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<PathBuf> {
|
pub fn readable_dirs() -> Vec<PathBuf> {
|
||||||
crate::model::State::path().parent().map(|d| vec![d.join("attachments")]).unwrap_or_default()
|
let mut v: Vec<PathBuf> = 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> {
|
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 {
|
for k in KEY_ENV_VARS {
|
||||||
c.env_remove(k);
|
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);
|
c.env(ENV_CONVERSATION_ID, conversation).env(ENV_REQUEST_ID, request);
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
|
|
@ -616,8 +631,9 @@ pub enum Place {
|
||||||
Local { workdir: PathBuf },
|
Local { workdir: PathBuf },
|
||||||
/// Another computer: `ssh target`, starting in `dir` (empty = its home).
|
/// Another computer: `ssh target`, starting in `dir` (empty = its home).
|
||||||
Ssh { target: String, dir: String },
|
Ssh { target: String, dir: String },
|
||||||
/// 裝置連結: another AnyBot of the same account (through the relay), by its host id.
|
/// 裝置連結: another AnyBot of the same account (through the relay), by its host id,
|
||||||
Device { host: String },
|
/// starting in `dir` (empty = its home).
|
||||||
|
Device { host: String, dir: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
|
|
@ -641,9 +657,8 @@ pub struct Roles {
|
||||||
pub fn specs_for(computers: &[String], default: &str) -> Vec<Value> {
|
pub fn specs_for(computers: &[String], default: &str) -> Vec<Value> {
|
||||||
let mut v = specs();
|
let mut v = specs();
|
||||||
for t in v.iter_mut() {
|
for t in v.iter_mut() {
|
||||||
if t["name"] == "read_terminal" || t["name"] == "write_terminal" {
|
// Every tool that works on a computer names one, background terminals included: a command
|
||||||
continue;
|
// 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
|
// 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.
|
// 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.");
|
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 {
|
match &target.place {
|
||||||
Place::Local { workdir } => run(name, args, workdir, cancel, ids),
|
Place::Local { workdir } => run(name, args, workdir, cancel, ids),
|
||||||
Place::Device { host } => match crate::remote::run_on_device(host, name, args, "") {
|
Place::Device { host, dir } => {
|
||||||
Ok(v) => (v["text"].as_str().unwrap_or("").to_string(), v["ok"].as_bool().unwrap_or(false)),
|
// 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),
|
Err(e) => (e, false),
|
||||||
},
|
}
|
||||||
|
}
|
||||||
Place::Sealed { root } => {
|
Place::Sealed { root } => {
|
||||||
let _ = std::fs::create_dir_all(root);
|
let _ = std::fs::create_dir_all(root);
|
||||||
if name == "shell" {
|
if name == "shell" {
|
||||||
|
|
@ -1235,7 +1270,12 @@ mod tests {
|
||||||
let w = wd();
|
let w = wd();
|
||||||
assert_eq!(decide("shell", r#"{"command":"ls && git status"}"#, &w, &[]), Decision::Allow);
|
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":"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!(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_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 { .. }));
|
assert!(matches!(decide("shell", r#"{"command":"echo hi > out.txt"}"#, &w, &[]), Decision::Ask { .. }));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue