1807 lines
88 KiB
Rust
1807 lines
88 KiB
Rust
// What a bot may do on this computer: list, search, read and edit files, run commands
|
||
// (long ones move to the background), and the rules that decide when to ask first.
|
||
//
|
||
// Limits (measured against the reference assistant's local tool runner):
|
||
// search: MAIN_TIMEOUT_MS=25e3, HARD_MAX_OUTPUT_LINES=1e4 (), client limit Jut=2e3 ()
|
||
// read: at most 8388608 bytes (gut,)
|
||
// shell: on timeout a background-able command keeps running and is handed off (Mye,/)
|
||
// Permission order follows the neighbouring investigation's design notes (fnd_8):
|
||
// deny > ignore > allow > ask, with chained shell commands split into parts first.
|
||
|
||
use serde_json::{json, Value};
|
||
use std::collections::HashMap;
|
||
use std::io::Read;
|
||
use std::path::{Path, PathBuf};
|
||
use std::process::{Command, Stdio};
|
||
use std::sync::atomic::{AtomicBool, Ordering};
|
||
use std::sync::{Arc, Mutex, OnceLock};
|
||
use std::time::{Duration, Instant};
|
||
|
||
pub const SEARCH_TIMEOUT_MS: u64 = 25_000;
|
||
pub const SEARCH_HARD_MAX_LINES: usize = 10_000;
|
||
pub const SEARCH_CLIENT_LIMIT_LINES: usize = 2_000;
|
||
pub const READ_MAX_BYTES: u64 = 8_388_608;
|
||
/// Lines returned by one read_file call when the model gives no limit (estimated).
|
||
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;
|
||
pub const OUTPUT_TAIL_CHARS: usize = 6_000;
|
||
|
||
/// Names the model sees, with JSON-schema parameters.
|
||
pub fn specs() -> Vec<Value> {
|
||
vec![
|
||
json!({"name": "list_dir", "description": "List the files and folders in a folder (relative to the work folder unless absolute).",
|
||
"parameters": {"type": "object", "properties": {"path": {"type": "string", "description": "Folder to list; empty means the work folder."}}, "required": []}}),
|
||
json!({"name": "search", "description": "Find text in files under a folder. Returns matching lines as path:line:text.",
|
||
"parameters": {"type": "object", "properties": {
|
||
"pattern": {"type": "string", "description": "Text to look for (regular expression when ripgrep is installed)."},
|
||
"path": {"type": "string", "description": "Folder or file to search; empty means the work folder."},
|
||
"files_only": {"type": "boolean", "description": "Only list the files that match."}}, "required": ["pattern"]}}),
|
||
json!({"name": "read_file", "description": "Read a text file. Lines come back numbered.",
|
||
"parameters": {"type": "object", "properties": {
|
||
"path": {"type": "string"},
|
||
"offset": {"type": "integer", "description": "First line to read (1-based)."},
|
||
"limit": {"type": "integer", "description": "How many lines to read."}}, "required": ["path"]}}),
|
||
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 (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"]}}),
|
||
json!({"name": "read_terminal", "description": "Read the latest output of a command that was moved to the background.",
|
||
"parameters": {"type": "object", "properties": {"id": {"type": "integer"}}, "required": ["id"]}}),
|
||
json!({"name": "write_terminal", "description": "Send input to a command running in the background, as if typed into its terminal (e.g. answer a prompt: \"y\\n\"). close=true ends its input (like Ctrl-D).",
|
||
"parameters": {"type": "object", "properties": {"id": {"type": "integer"}, "input": {"type": "string", "description": "Text to send; include \\n to press return."}, "close": {"type": "boolean"}}, "required": ["id"]}}),
|
||
]
|
||
}
|
||
|
||
/// 請你接手: the step that asks the person to do something by hand on a computer.
|
||
pub const HANDOFF_TOOL: &str = "hand_over_computer";
|
||
|
||
pub fn label(name: &str) -> &'static str {
|
||
match name {
|
||
HANDOFF_TOOL => "請你接手電腦",
|
||
"list_dir" => "查看資料夾",
|
||
"search" => "搜尋檔案",
|
||
"read_file" => "讀取檔案",
|
||
"edit_file" => "修改檔案",
|
||
"shell" => "執行指令",
|
||
"read_terminal" => "查看背景指令",
|
||
"write_terminal" => "輸入到背景指令",
|
||
"open_for_person" => "打開給你看",
|
||
"send_file" => "傳檔案給你",
|
||
"react" => "按表情",
|
||
"view_demo_step" => "看示範",
|
||
"publish_site" => "發布網站",
|
||
"place_call" => "打電話",
|
||
"send_sms" => "傳簡訊",
|
||
"post_to_x" => "在 X 發文",
|
||
"show_card" => "顯示卡片",
|
||
"vault_list" => "查看保險庫",
|
||
"browser_fill_login" => "用保險庫登入",
|
||
"browser_fill_card" => "填入卡片",
|
||
"browser_fill_contact" => "填入聯絡資訊",
|
||
_ => "工具",
|
||
}
|
||
}
|
||
|
||
/// A tool call's arguments (none = an empty object).
|
||
pub fn args_of(args: &str) -> Value {
|
||
serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or(Value::Null)
|
||
}
|
||
|
||
fn arg_str(v: &Value, k: &str) -> String {
|
||
v.get(k).and_then(Value::as_str).unwrap_or("").to_string()
|
||
}
|
||
|
||
/// One short line describing a call, for the chat and the approval card.
|
||
pub fn describe(name: &str, args: &str) -> String {
|
||
let v = args_of(args);
|
||
match name {
|
||
"shell" => arg_str(&v, "command"),
|
||
"search" => format!("「{}」 {}", arg_str(&v, "pattern"), arg_str(&v, "path")).trim().to_string(),
|
||
"read_terminal" => format!("背景指令 #{}", v.get("id").and_then(Value::as_u64).unwrap_or(0)),
|
||
"write_terminal" => {
|
||
let input: String = arg_str(&v, "input").chars().take(40).collect();
|
||
format!("背景指令 #{}:{}{}", v.get("id").and_then(Value::as_u64).unwrap_or(0), input.escape_debug(), if v.get("close").and_then(Value::as_bool).unwrap_or(false) { "(結束輸入)" } else { "" })
|
||
}
|
||
"vault_list" => match arg_str(&v, "kind").as_str() {
|
||
"login" => "登入資訊".into(),
|
||
"card" => "卡片".into(),
|
||
"contact" => "聯絡資訊".into(),
|
||
_ => "全部項目".into(),
|
||
},
|
||
"browser_fill_login" => {
|
||
let site = arg_str(&v, "site");
|
||
if site.is_empty() { "目前的網站".into() } else { site }
|
||
}
|
||
"browser_fill_card" | "browser_fill_contact" => "目前的網頁".into(),
|
||
_ => {
|
||
let p = arg_str(&v, "path");
|
||
if p.is_empty() { "工作資料夾".into() } else { p }
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------- paths ----------
|
||
|
||
pub fn expand_home(p: &str) -> PathBuf {
|
||
if let Some(rest) = p.strip_prefix("~/") {
|
||
if let Some(h) = dirs::home_dir() {
|
||
return h.join(rest);
|
||
}
|
||
} else if p == "~" {
|
||
if let Some(h) = dirs::home_dir() {
|
||
return h;
|
||
}
|
||
}
|
||
PathBuf::from(p)
|
||
}
|
||
|
||
/// Make a path absolute and tidy (no `.` or `..`) without touching the disk.
|
||
pub fn resolve(workdir: &Path, p: &str) -> PathBuf {
|
||
let raw = if p.trim().is_empty() { workdir.to_path_buf() } else {
|
||
let e = expand_home(p.trim());
|
||
if e.is_absolute() { e } else { workdir.join(e) }
|
||
};
|
||
let mut out = PathBuf::new();
|
||
for c in raw.components() {
|
||
match c {
|
||
std::path::Component::ParentDir => {
|
||
out.pop();
|
||
}
|
||
std::path::Component::CurDir => {}
|
||
other => out.push(other),
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
fn inside(workdir: &Path, p: &Path) -> bool {
|
||
p.starts_with(workdir)
|
||
}
|
||
|
||
/// Files a bot never reads or writes: secrets and version-control internals
|
||
/// (the original blocks reads/writes by ignore files and repo-block globs),
|
||
/// plus this app's own data folder, where API keys are saved.
|
||
pub fn is_ignored(p: &Path) -> bool {
|
||
if let Some(d) = crate::model::State::path().parent() {
|
||
if p.starts_with(d) {
|
||
return true;
|
||
}
|
||
}
|
||
let s = p.to_string_lossy().replace('\\', "/");
|
||
let name = p.file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
|
||
s.contains("/.git/") || s.ends_with("/.git") || s.contains("/.ssh/") || s.contains("/.gnupg/") || s.contains("/Library/Keychains")
|
||
|| name == ".env" || name.starts_with(".env.") || name.ends_with(".pem") || name.ends_with(".key") || name.starts_with("id_rsa") || name.starts_with("id_ed25519")
|
||
|| name == ".netrc" || name == "credentials"
|
||
}
|
||
|
||
// ---------- permissions ----------
|
||
|
||
#[derive(Debug, PartialEq, Clone)]
|
||
pub enum Decision {
|
||
Allow,
|
||
Ask { reason: String, grant: String },
|
||
Deny(String),
|
||
}
|
||
|
||
/// Commands that only look at things; chains made only of these run without asking.
|
||
const READ_ONLY_COMMANDS: [&str; 24] = [
|
||
"ls", "pwd", "cat", "head", "tail", "wc", "echo", "rg", "grep", "find", "file", "stat", "du", "df", "date", "which", "tree", "sort", "uniq", "diff",
|
||
"git status", "git diff", "git log", "git show",
|
||
];
|
||
|
||
/// Always refused.
|
||
const DENIED_COMMANDS: [&str; 10] = ["sudo", "su", "rm -rf /", "rm -rf ~", "mkfs", "dd", "shutdown", "reboot", "diskutil erase", "chmod -R 777 /"];
|
||
|
||
/// Split `a && b | c; d` into its commands, keeping quoted text together.
|
||
/// Returns None when the command hides other commands ($(), backticks, eval).
|
||
pub fn split_commands(cmd: &str) -> Option<Vec<String>> {
|
||
let mut parts = vec![];
|
||
let mut cur = String::new();
|
||
let (mut sq, mut dq) = (false, false);
|
||
let chars: Vec<char> = cmd.chars().collect();
|
||
let mut i = 0;
|
||
while i < chars.len() {
|
||
let c = chars[i];
|
||
let next = chars.get(i + 1).copied();
|
||
if c == '\'' && !dq {
|
||
sq = !sq;
|
||
} else if c == '"' && !sq {
|
||
dq = !dq;
|
||
} else if !sq && (c == '`' || (c == '$' && next == Some('('))) {
|
||
return None;
|
||
} else if !sq && !dq && (c == ';' || c == '\n' || c == '|' || c == '&') {
|
||
if !cur.trim().is_empty() {
|
||
parts.push(cur.trim().to_string());
|
||
}
|
||
cur.clear();
|
||
if (c == '|' || c == '&') && next == Some(c) {
|
||
i += 1;
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
cur.push(c);
|
||
i += 1;
|
||
}
|
||
if sq || dq {
|
||
return None;
|
||
}
|
||
if !cur.trim().is_empty() {
|
||
parts.push(cur.trim().to_string());
|
||
}
|
||
if parts.iter().any(|p| { let w = p.split_whitespace().next().unwrap_or(""); w == "eval" || w == "exec" || w == "source" || w == "." }) {
|
||
return None;
|
||
}
|
||
Some(parts)
|
||
}
|
||
|
||
fn words(cmd: &str) -> Vec<&str> {
|
||
cmd.split_whitespace().collect()
|
||
}
|
||
|
||
fn starts_with_words(cmd: &str, prefix: &str) -> bool {
|
||
let (c, p) = (words(cmd), words(prefix));
|
||
!p.is_empty() && c.len() >= p.len() && c[..p.len()] == p[..]
|
||
}
|
||
|
||
/// The denied command hiding inside a wrapper: `(sudo …)`, `FOO=1 sudo …`, `env sudo …`,
|
||
/// `nohup sudo …` all run the same thing, so peel the wrappings off before matching.
|
||
fn denied_hit(part: &str) -> Option<&'static str> {
|
||
let mut toks: Vec<String> = Vec::new();
|
||
for w in words(part) {
|
||
let mut w = w;
|
||
while matches!(w.as_bytes().first(), Some(b'(') | Some(b'{')) {
|
||
w = &w[1..];
|
||
}
|
||
if w.is_empty() {
|
||
continue;
|
||
}
|
||
// VAR=val prefixes set the environment; they are not the command.
|
||
let is_assign = w.split_once('=').map(|(k, v)| !k.is_empty() && v.len() + 1 < w.len() && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')).unwrap_or(false);
|
||
if toks.is_empty() && is_assign {
|
||
continue;
|
||
}
|
||
toks.push(w.to_string());
|
||
}
|
||
const WRAP: [&str; 7] = ["env", "command", "nohup", "time", "nice", "stdbuf", "timeout"];
|
||
while toks.first().map(|t| WRAP.contains(&t.as_str())).unwrap_or(false) {
|
||
toks.remove(0);
|
||
while toks.first().map(|t| t.starts_with('-')).unwrap_or(false) {
|
||
toks.remove(0);
|
||
}
|
||
// timeout takes a duration before the command.
|
||
if toks.first().map(|t| !t.is_empty() && t.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false)).unwrap_or(false) {
|
||
toks.remove(0);
|
||
}
|
||
}
|
||
let joined = toks.join(" ");
|
||
DENIED_COMMANDS.iter().find(|d| starts_with_words(&joined, d)).copied()
|
||
}
|
||
|
||
/// The "always allow" grant for a command: its first word, or first two for git/npm/cargo-style tools.
|
||
pub fn shell_grant(part: &str) -> String {
|
||
let w = words(part);
|
||
let two = ["git", "npm", "pnpm", "yarn", "cargo", "brew", "pip", "pip3", "python3", "docker", "go"];
|
||
let key = if w.len() >= 2 && two.contains(&w[0]) { format!("{} {}", w[0], w[1]) } else { w.first().map(|s| s.to_string()).unwrap_or_default() };
|
||
format!("shell:{key}")
|
||
}
|
||
|
||
fn writes_files(part: &str) -> bool {
|
||
part.contains('>')
|
||
}
|
||
|
||
pub fn decide(name: &str, args: &str, workdir: &Path, grants: &[String]) -> Decision {
|
||
let v = args_of(args);
|
||
let granted = |g: &str| grants.iter().any(|x| x == g);
|
||
match name {
|
||
"list_dir" | "search" | "read_file" => {
|
||
let p = resolve(workdir, &arg_str(&v, "path"));
|
||
if is_ignored(&p) {
|
||
return Decision::Deny(format!("「{}」是私密或系統檔案,Bot 不能讀取。", p.display()));
|
||
}
|
||
if inside(workdir, &p) || granted("read:anywhere") {
|
||
Decision::Allow
|
||
} else {
|
||
Decision::Ask { reason: format!("讀取工作資料夾以外的地方:{}", p.display()), grant: "read:anywhere".into() }
|
||
}
|
||
}
|
||
"read_terminal" | "write_terminal" => Decision::Allow,
|
||
"edit_file" => {
|
||
let p = resolve(workdir, &arg_str(&v, "path"));
|
||
if is_ignored(&p) {
|
||
return Decision::Deny(format!("「{}」是私密或系統檔案,Bot 不能修改。", p.display()));
|
||
}
|
||
if !inside(workdir, &p) {
|
||
return Decision::Ask { reason: format!("修改工作資料夾以外的檔案:{}", p.display()), grant: String::new() };
|
||
}
|
||
if granted("edit_file") {
|
||
Decision::Allow
|
||
} else {
|
||
Decision::Ask { reason: format!("修改檔案:{}", p.display()), grant: "edit_file".into() }
|
||
}
|
||
}
|
||
"shell" => {
|
||
let cmd = arg_str(&v, "command");
|
||
if cmd.trim().is_empty() {
|
||
return Decision::Deny("沒有指令".into());
|
||
}
|
||
let Some(parts) = split_commands(&cmd) else {
|
||
// 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 {
|
||
if let Some(d) = denied_hit(part) {
|
||
return Decision::Deny(format!("「{d}」這類指令一律不允許。"));
|
||
}
|
||
}
|
||
// (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();
|
||
if unapproved.is_empty() {
|
||
return Decision::Allow;
|
||
}
|
||
let grant = if unapproved.len() == 1 && !writes_files(unapproved[0]) { shell_grant(unapproved[0]) } else { String::new() };
|
||
Decision::Ask { reason: format!("執行指令:{cmd}"), grant }
|
||
}
|
||
other => Decision::Deny(format!("沒有「{other}」這個工具")),
|
||
}
|
||
}
|
||
|
||
// ---------- process trees ----------
|
||
// From (the original's native process scanner):
|
||
// getDescendantPids: breadth-first over children by parent pid (proc_listpids PROC_PPID_ONLY),
|
||
// de-duplicated, stops once 0xfff (4095) pids are collected → at most 4096 processes.
|
||
//: scans the roots plus all descendants.
|
||
// getProcessInformation: memoryMB = resident bytes >> 20 (lsr #0x14);
|
||
// cpuTimeMs = (user + system time, in Mach ticks × timebase) / 1 000 000;
|
||
// each process is tied to its agent by CONVERSATION_ID= / REQUEST_ID= in its environment.
|
||
|
||
pub const MAX_TRACKED_PROCESSES: usize = 4096;
|
||
/// Our own names for the tags the original reads (it uses its vendor prefix).
|
||
pub const ENV_CONVERSATION_ID: &str = "ANYBOT_CONVERSATION_ID";
|
||
pub const ENV_REQUEST_ID: &str = "ANYBOT_REQUEST_ID";
|
||
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub struct ProcRow {
|
||
pub pid: u32,
|
||
pub ppid: u32,
|
||
pub rss_kb: u64,
|
||
pub cpu_ms: u64,
|
||
}
|
||
|
||
/// `[[dd-]hh:]mm:ss[.cc]` as printed by ps, in milliseconds.
|
||
pub fn parse_cpu_time(s: &str) -> u64 {
|
||
let (days, rest) = match s.split_once('-') {
|
||
Some((d, r)) => (d.parse::<u64>().unwrap_or(0), r),
|
||
None => (0, s),
|
||
};
|
||
let mut secs = 0f64;
|
||
for part in rest.split(':') {
|
||
secs = secs * 60.0 + part.parse::<f64>().unwrap_or(0.0);
|
||
}
|
||
days * 86_400_000 + (secs * 1000.0).round() as u64
|
||
}
|
||
|
||
pub fn proc_table() -> Vec<ProcRow> {
|
||
let Ok(out) = Command::new("ps").args(["-A", "-o", "pid=,ppid=,rss=,time="]).output() else { return vec![] };
|
||
String::from_utf8_lossy(&out.stdout)
|
||
.lines()
|
||
.filter_map(|l| {
|
||
let f: Vec<&str> = l.split_whitespace().collect();
|
||
(f.len() >= 4).then(|| ProcRow { pid: f[0].parse().unwrap_or(0), ppid: f[1].parse().unwrap_or(0), rss_kb: f[2].parse().unwrap_or(0), cpu_ms: parse_cpu_time(f[3]) })
|
||
})
|
||
.filter(|r| r.pid > 0)
|
||
.collect()
|
||
}
|
||
|
||
/// The roots and everything they started, breadth-first, without repeats, at most 4096.
|
||
pub fn descendants(table: &[ProcRow], roots: &[u32]) -> Vec<u32> {
|
||
let mut seen = std::collections::HashSet::new();
|
||
let mut out = vec![];
|
||
let mut queue: std::collections::VecDeque<u32> = roots.iter().copied().filter(|p| *p > 0).collect();
|
||
while let Some(p) = queue.pop_front() {
|
||
if !seen.insert(p) {
|
||
continue;
|
||
}
|
||
out.push(p);
|
||
if out.len() >= MAX_TRACKED_PROCESSES {
|
||
break;
|
||
}
|
||
queue.extend(table.iter().filter(|r| r.ppid == p).map(|r| r.pid));
|
||
}
|
||
out
|
||
}
|
||
|
||
/// (processes, cpu ms, memory MB) for a command and everything it started.
|
||
pub fn tree_stats(root: u32) -> (usize, u64, u64) {
|
||
let table = proc_table();
|
||
let pids = descendants(&table, &[root]);
|
||
let rows: Vec<&ProcRow> = table.iter().filter(|r| pids.contains(&r.pid)).collect();
|
||
(rows.len(), rows.iter().map(|r| r.cpu_ms).sum(), rows.iter().map(|r| (r.rss_kb * 1024) >> 20).sum())
|
||
}
|
||
|
||
/// Stop a command and everything it started (newest first), not just the shell.
|
||
pub fn kill_tree(root: u32) {
|
||
let table = proc_table();
|
||
let mut pids = descendants(&table, &[root]);
|
||
pids.reverse();
|
||
if pids.is_empty() {
|
||
return;
|
||
}
|
||
let _ = Command::new("kill").arg("-TERM").args(pids.iter().map(|p| p.to_string())).stderr(Stdio::null()).status();
|
||
}
|
||
|
||
// ---------- sandbox ----------
|
||
// From sand-op-launcher `_main`: before exec it applies a sandbox profile
|
||
// "(version 1)(allow default)(deny file-read* (with no-report) (subpath (param …))))" that hides
|
||
// one private folder, builds a fresh environment, and refuses invocations outside an exact list.
|
||
// Here: commands can't read or write the app's own data (where API keys are saved) or the
|
||
// usual secret folders, and API keys are removed from their environment.
|
||
|
||
const KEY_ENV_VARS: [&str; 6] = ["XAI_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "OPENROUTER_API_KEY", "ANTHROPIC_AUTH_TOKEN"];
|
||
|
||
pub fn protected_dirs() -> Vec<PathBuf> {
|
||
let mut v = vec![];
|
||
if let Some(d) = crate::model::State::path().parent() {
|
||
v.push(d.to_path_buf());
|
||
}
|
||
if let Some(h) = dirs::home_dir() {
|
||
for s in [".ssh", ".gnupg", "Library/Keychains", ".aws", ".config/gcloud"] {
|
||
v.push(h.join(s));
|
||
}
|
||
}
|
||
v
|
||
}
|
||
|
||
/// 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) and the ssh keys, so
|
||
/// git and ssh can reach remote repos (writing them stays denied).
|
||
pub fn readable_dirs() -> Vec<PathBuf> {
|
||
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> {
|
||
let p = "/usr/bin/sandbox-exec";
|
||
(cfg!(target_os = "macos") && Path::new(p).exists()).then_some(p)
|
||
}
|
||
|
||
/// Can the bot's own computer run commands without Docker? Only inside a sandbox (macOS):
|
||
/// elsewhere a command there would run on this computer itself, as AnyBot, keys and all.
|
||
pub fn sealed_runs_commands() -> bool {
|
||
sandbox_exec().is_some()
|
||
}
|
||
|
||
/// Why the bot's own computer can't run a command right now.
|
||
pub const NO_BOX_FOR_COMMANDS: &str = "你自己的電腦現在不能執行指令:AnyBot 所在的這台沒有 Docker(或連不到 Docker),沒有隔離的地方可以跑指令。請告訴使用者把 Docker 打開,或請他指名別台電腦來做。";
|
||
|
||
fn real(p: &Path) -> String {
|
||
// The sandbox matches real paths, so resolve links like /var → /private/var.
|
||
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()).display().to_string()
|
||
}
|
||
|
||
/// The shell command, wrapped in the sandbox when the system has one.
|
||
#[cfg(test)]
|
||
pub fn build_command(cmd: &str, workdir: &Path, protected: &[PathBuf], conversation: &str, request: &str) -> Command {
|
||
build_command_sealed(cmd, workdir, protected, &[], None, conversation, request)
|
||
}
|
||
|
||
/// Characters that join, redirect or substitute commands: a command with any of them is not one
|
||
/// plain program call.
|
||
const SHELL_JOINERS: [&str; 9] = [";", "&", "|", "<", ">", "`", "$(", "\n", "\\"];
|
||
|
||
/// The program a plain one-program command runs, quotes stripped ("" when it isn't one).
|
||
fn plain_program(cmd: &str) -> Option<String> {
|
||
let c = cmd.trim();
|
||
if c.is_empty() || SHELL_JOINERS.iter().any(|j| c.contains(j)) {
|
||
return None;
|
||
}
|
||
let first = if let Some(rest) = c.strip_prefix('"') {
|
||
rest.split('"').next()?.to_string()
|
||
} else if let Some(rest) = c.strip_prefix('\'') {
|
||
rest.split('\'').next()?.to_string()
|
||
} else {
|
||
c.split_whitespace().next()?.to_string()
|
||
};
|
||
Some(first)
|
||
}
|
||
|
||
/// An App Store app's own command line (`/Applications/X.app/Contents/MacOS/Y`): it carries
|
||
/// Apple's app sandbox, so outside AnyBot's sandbox it still can't read the secret folders —
|
||
/// and inside it, the two sandboxes clash and the program dies at once (Tailscale's CLI:
|
||
/// "Trace/BPT trap: 5"). Checked once per program with codesign.
|
||
fn app_sandboxed_cli(program: &str) -> bool {
|
||
let p = Path::new(program);
|
||
let in_app = p.is_absolute()
|
||
&& !program.contains("..")
|
||
&& p.components().count() >= 6
|
||
&& program.starts_with("/Applications/")
|
||
&& program.split('/').nth(2).map(|a| a.ends_with(".app")).unwrap_or(false)
|
||
&& program.contains(".app/Contents/MacOS/");
|
||
if !in_app || !p.is_file() {
|
||
return false;
|
||
}
|
||
static SEEN: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
|
||
let seen = SEEN.get_or_init(|| Mutex::new(HashMap::new()));
|
||
if let Some(v) = seen.lock().unwrap_or_else(|e| e.into_inner()).get(program) {
|
||
return *v;
|
||
}
|
||
let ok = Command::new("/usr/bin/codesign")
|
||
.args(["-d", "--entitlements", "-", "--xml", program])
|
||
.stderr(Stdio::null())
|
||
.output()
|
||
.map(|o| {
|
||
let x = String::from_utf8_lossy(&o.stdout).replace(['\n', '\t', ' '], "");
|
||
x.contains("<key>com.apple.security.app-sandbox</key><true/>")
|
||
})
|
||
.unwrap_or(false);
|
||
seen.lock().unwrap_or_else(|e| e.into_inner()).insert(program.to_string(), ok);
|
||
ok
|
||
}
|
||
|
||
/// `ps` / `top`, alone or piped into plain text filters that take no file: they need system
|
||
/// rights (setuid), which no sandboxed process gets ("/bin/ps: Operation not permitted"), and
|
||
/// they read no files.
|
||
fn process_listing(cmd: &str) -> bool {
|
||
let c = cmd.trim();
|
||
if [";", "&", "<", ">", "`", "$(", "\n", "\\", "~"].iter().any(|j| c.contains(j)) {
|
||
return false;
|
||
}
|
||
let mut parts = c.split('|').map(str::trim);
|
||
let Some(first) = parts.next() else { return false };
|
||
let mut w = first.split_whitespace();
|
||
let prog = w.next().unwrap_or("");
|
||
if !matches!(prog, "ps" | "/bin/ps" | "top" | "/usr/bin/top") || w.any(|a| a.contains('/')) {
|
||
return false;
|
||
}
|
||
parts.all(|p| {
|
||
let mut w = p.split_whitespace();
|
||
matches!(w.next().unwrap_or(""), "grep" | "egrep" | "head" | "tail" | "wc" | "sort" | "uniq" | "cut" | "awk") && !p.contains('/')
|
||
})
|
||
}
|
||
|
||
/// A command's words the way the shell reads them, or None when it is more than one plain
|
||
/// program call: anything outside quotes that joins, redirects, pipes, backgrounds or
|
||
/// substitutes (`;` `&` `|` `<` `>` `(` `)` a backtick, `$(`, a line break), and substitution
|
||
/// inside double quotes. Quotes, backslashes and `&` inside an AppleScript string are fine.
|
||
pub fn plain_words(cmd: &str) -> Option<Vec<String>> {
|
||
let mut words = vec![];
|
||
let mut cur = String::new();
|
||
let mut started = false;
|
||
let mut it = cmd.trim().chars().peekable();
|
||
while let Some(c) = it.next() {
|
||
match c {
|
||
'\'' => {
|
||
started = true;
|
||
loop {
|
||
match it.next()? {
|
||
'\'' => break,
|
||
ch => cur.push(ch),
|
||
}
|
||
}
|
||
}
|
||
'"' => {
|
||
started = true;
|
||
loop {
|
||
match it.next()? {
|
||
'"' => break,
|
||
'\\' => match it.next()? {
|
||
e @ ('"' | '\\' | '$' | '`') => cur.push(e),
|
||
'\n' => {}
|
||
e => {
|
||
cur.push('\\');
|
||
cur.push(e);
|
||
}
|
||
},
|
||
'`' => return None,
|
||
'$' if it.peek() == Some(&'(') => return None,
|
||
ch => cur.push(ch),
|
||
}
|
||
}
|
||
}
|
||
'\\' => {
|
||
started = true;
|
||
match it.next()? {
|
||
'\n' => {}
|
||
e => cur.push(e),
|
||
}
|
||
}
|
||
';' | '&' | '|' | '<' | '>' | '(' | ')' | '`' | '\n' => return None,
|
||
'$' if it.peek() == Some(&'(') => return None,
|
||
c if c.is_whitespace() => {
|
||
if started {
|
||
words.push(std::mem::take(&mut cur));
|
||
started = false;
|
||
}
|
||
}
|
||
c => {
|
||
started = true;
|
||
cur.push(c);
|
||
}
|
||
}
|
||
}
|
||
if started {
|
||
words.push(cur);
|
||
}
|
||
(!words.is_empty()).then_some(words)
|
||
}
|
||
|
||
/// What an AppleScript run outside the sandbox may not do: run shell commands or other
|
||
/// scripts, drive a terminal or type keystrokes (into one), read files or reach the Objective-C
|
||
/// bridge, or name the secret folders. Outside the sandbox nothing else stops it, so this list
|
||
/// errs on the side of refusing; refused scripts still run, inside the sandbox.
|
||
const OSASCRIPT_OUTSIDE_REFUSED: [&str; 24] = [
|
||
"do shell script", "run script", "load script", "store script", "do script", "terminal", "iterm", "warp", "ghostty", "keystroke", "key code",
|
||
"objc", "$.ns", "nsstring", "nsdata", "nsfilemanager", "read file", "read posix file", "open for access", ".ssh", "keychain", ".gnupg", ".aws", "gcloud",
|
||
];
|
||
|
||
/// A plain `osascript -e …` call that may run outside the sandbox (Apple events to other apps
|
||
/// only work there: inside, every app answers -10004), and why not when it may not.
|
||
pub fn osascript_outside(cmd: &str) -> Result<(), String> {
|
||
let words = plain_words(cmd).ok_or("不是單獨一條 osascript 指令")?;
|
||
if !matches!(words[0].as_str(), "osascript" | "/usr/bin/osascript") {
|
||
return Err("不是 osascript".into());
|
||
}
|
||
// Only inline AppleScript (-e …): no script files (unseen code), no JavaScript (its ObjC
|
||
// bridge reads anything), no interactive mode.
|
||
let mut script = String::new();
|
||
let mut i = 1;
|
||
while i < words.len() {
|
||
match words[i].as_str() {
|
||
"-e" if i + 1 < words.len() => {
|
||
script.push_str(&words[i + 1]);
|
||
script.push('\n');
|
||
i += 2;
|
||
}
|
||
"-s" if i + 1 < words.len() => i += 2,
|
||
w if w.starts_with("-s") => i += 1,
|
||
_ => return Err("只能用 -e 寫 AppleScript(不能跑腳本檔、JavaScript 或互動模式)".into()),
|
||
}
|
||
}
|
||
if script.trim().is_empty() {
|
||
return Err("沒有 -e 的 AppleScript".into());
|
||
}
|
||
let low = script.to_lowercase();
|
||
let home = dirs::home_dir().map(|h| h.display().to_string().to_lowercase()).unwrap_or_default();
|
||
let data = crate::model::State::path().parent().map(|d| d.display().to_string().to_lowercase()).unwrap_or_default();
|
||
if let Some(bad) = OSASCRIPT_OUTSIDE_REFUSED.iter().find(|b| low.contains(*b)) {
|
||
return Err(format!("用到了「{bad}」"));
|
||
}
|
||
if (!data.is_empty() && low.contains(&data)) || low.contains("application support/anybot") || (!home.is_empty() && low.contains(&format!("{home}/library/keychains"))) {
|
||
return Err("碰到 AnyBot 的資料夾或鑰匙圈".into());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Commands that run outside AnyBot's sandbox on the person's Mac, because the sandbox breaks
|
||
/// them and they can't reach the secret folders anyway (see app_sandboxed_cli, process_listing),
|
||
/// or because the person allowed AppleScript to drive their apps (osascript_outside; asked first).
|
||
pub fn runs_outside_sandbox(cmd: &str) -> bool {
|
||
if !cfg!(target_os = "macos") {
|
||
return false;
|
||
}
|
||
process_listing(cmd) || osascript_outside(cmd).is_ok() || plain_program(cmd).map(|p| app_sandboxed_cli(&p)).unwrap_or(false)
|
||
}
|
||
|
||
/// The grant that lets AppleScript run outside the sandbox without asking again.
|
||
pub const OSASCRIPT_GRANT: &str = "osascript:outside";
|
||
|
||
/// A word for the bot when a command failed because of the sandbox, not because the program or
|
||
/// the computer is broken — so it changes course instead of trying the same thing again.
|
||
pub fn sandbox_note(cmd: &str, out: &str) -> Option<&'static str> {
|
||
sandbox_exec()?;
|
||
if runs_outside_sandbox(cmd) {
|
||
return None;
|
||
}
|
||
let setuid = ["/bin/ps: Operation not permitted", "/usr/bin/top: Operation not permitted", "sudo: Operation not permitted", "/usr/bin/sudo: Operation not permitted"];
|
||
if setuid.iter().any(|s| out.contains(s)) {
|
||
return Some("(這是 AnyBot 的指令沙盒擋下的,不是電腦壞了:ps、top、sudo 這類要系統權限的程式在沙盒裡不能跑。查程序改用 pgrep -fl 名字,或把 ps/top 單獨執行(後面只接 grep、head 這類過濾),那樣會在沙盒外跑。)");
|
||
}
|
||
if out.contains("(-10004)") || out.contains("越權取用") || out.to_lowercase().contains("privilege violation") {
|
||
return Some(match osascript_outside(cmd) {
|
||
// Plain osascript that still got -10004 can't be the sandbox: macOS itself said no.
|
||
Ok(()) => return None,
|
||
Err(_) if cmd.contains("osascript") => "(這是 AnyBot 的指令沙盒擋下的,不是那個 App 壞了:AppleScript 只有寫成單獨一條 osascript -e '…' 指令(不接其他指令、不用 heredoc,多行就多個 -e),而且不跑 shell 指令、不碰終端機、不讀檔案時,才會在沙盒外跑、能指揮別的 App。改寫成那樣,或改用電腦操作(computer 工具的 action,如 click、typing)。)",
|
||
Err(_) => "(這是 AnyBot 的指令沙盒擋下的,不是那個 App 壞了:在沙盒裡不能指揮別的 App(-10004)。改用電腦操作(computer 工具的 action),或寫成單獨一條 osascript -e '…' 指令。)",
|
||
});
|
||
}
|
||
if (out.contains("Trace/BPT trap") || out.contains("指令因為收到訊號而終止")) && cmd.contains(".app/Contents/MacOS/") {
|
||
return Some("(這個 App 的指令列在 AnyBot 的沙盒裡會當掉,不是 App 壞了,也不是參數的問題。用它的完整路徑單獨執行:不要接 ;、|、&&,也不要用 >、< 把輸出存到檔案(要處理輸出就先單獨執行,再用下一條指令處理),App Store 版的 App 就會在沙盒外跑;不行就改用畫面操作。)");
|
||
}
|
||
None
|
||
}
|
||
|
||
/// `seal = Some(home)`: the bot's own computer. Nothing under the person's home folder is
|
||
/// visible or writable except the bot's folder (`workdir`), like a separate machine.
|
||
/// `readable`: folders inside `protected` that may still be read (never written).
|
||
pub fn build_command_sealed(cmd: &str, workdir: &Path, protected: &[PathBuf], readable: &[PathBuf], seal: Option<&Path>, conversation: &str, request: &str) -> Command {
|
||
let mut c = match sandbox_exec() {
|
||
Some(se) if (!protected.is_empty() || seal.is_some()) && !(seal.is_none() && runs_outside_sandbox(cmd)) => {
|
||
let rules: String = (0..protected.len()).map(|i| format!(" (subpath (param \"P{i}\"))")).collect();
|
||
let mut profile = format!("(version 1)(allow default)(deny file-read* file-write* (with no-report){rules})");
|
||
if seal.is_some() {
|
||
profile.push_str("(deny file-read* file-write* (with no-report) (require-all (subpath (param \"HOME\")) (require-not (subpath (param \"ROOT\")))))");
|
||
}
|
||
// Later rules win: reading these is allowed again (writing stays denied).
|
||
let open: Vec<&PathBuf> = readable.iter().filter(|r| protected.iter().any(|p| r.starts_with(p))).collect();
|
||
for i in 0..open.len() {
|
||
profile.push_str(&format!("(allow file-read* (subpath (param \"R{i}\")))"));
|
||
}
|
||
let mut c = Command::new(se);
|
||
for (i, p) in protected.iter().enumerate() {
|
||
c.arg("-D").arg(format!("P{i}={}", real(p)));
|
||
}
|
||
for (i, r) in open.iter().enumerate() {
|
||
c.arg("-D").arg(format!("R{i}={}", real(r)));
|
||
}
|
||
if let Some(home) = seal {
|
||
c.arg("-D").arg(format!("HOME={}", real(home))).arg("-D").arg(format!("ROOT={}", real(workdir)));
|
||
}
|
||
c.arg("-p").arg(profile).arg("/bin/sh").arg("-c").arg(cmd);
|
||
c
|
||
}
|
||
_ => {
|
||
let mut c = Command::new("/bin/sh");
|
||
c.arg("-c").arg(cmd);
|
||
c
|
||
}
|
||
};
|
||
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)]
|
||
{
|
||
use std::os::unix::process::CommandExt;
|
||
c.process_group(0);
|
||
}
|
||
c.current_dir(workdir).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||
c
|
||
}
|
||
|
||
/// A command AnyBot itself wraps around the bot's (ssh / docker exec): no sandbox, because the
|
||
/// wrapper needs the person's SSH keys and Docker settings; the bot's part runs on the other machine.
|
||
pub fn build_wrapper_command(cmd: &str, conversation: &str, request: &str) -> Command {
|
||
let mut c = Command::new("/bin/sh");
|
||
c.arg("-c").arg(cmd);
|
||
for k in KEY_ENV_VARS {
|
||
c.env_remove(k);
|
||
}
|
||
c.env(ENV_CONVERSATION_ID, conversation).env(ENV_REQUEST_ID, request).env("PATH", crate::cloudbox::tool_path());
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::process::CommandExt;
|
||
c.process_group(0);
|
||
}
|
||
c.current_dir(std::env::temp_dir()).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||
c
|
||
}
|
||
|
||
// ---------- running tools ----------
|
||
|
||
/// Which chat and which tool call a command belongs to.
|
||
#[derive(Clone, Default)]
|
||
pub struct RunIds {
|
||
pub conversation: String,
|
||
pub request: String,
|
||
}
|
||
|
||
struct Terminal {
|
||
command: String,
|
||
stdin: Arc<Mutex<Option<std::process::ChildStdin>>>,
|
||
output: Arc<Mutex<String>>,
|
||
child: Arc<Mutex<std::process::Child>>,
|
||
exit: Arc<Mutex<Option<i32>>>,
|
||
root: u32,
|
||
/// The bot it belongs to (the ANYBOT_CONVERSATION_ID tag).
|
||
conversation: String,
|
||
}
|
||
|
||
fn terminals() -> &'static Mutex<HashMap<u64, Terminal>> {
|
||
static T: OnceLock<Mutex<HashMap<u64, Terminal>>> = OnceLock::new();
|
||
T.get_or_init(|| Mutex::new(HashMap::new()))
|
||
}
|
||
|
||
fn next_terminal_id() -> u64 {
|
||
static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||
N.fetch_add(1, Ordering::Relaxed)
|
||
}
|
||
|
||
/// Keep the start and the end of long output (the original's output_head / output_tail).
|
||
pub fn head_tail(s: &str) -> String {
|
||
let n = s.chars().count();
|
||
if n <= OUTPUT_HEAD_CHARS + OUTPUT_TAIL_CHARS {
|
||
return s.to_string();
|
||
}
|
||
let head: String = s.chars().take(OUTPUT_HEAD_CHARS).collect();
|
||
let tail: String = s.chars().skip(n - OUTPUT_TAIL_CHARS).collect();
|
||
format!("{head}\n…(中間省略 {} 個字)…\n{tail}", n - OUTPUT_HEAD_CHARS - OUTPUT_TAIL_CHARS)
|
||
}
|
||
|
||
fn timeout_of(v: &Value) -> u64 {
|
||
v.get("timeout_ms").and_then(Value::as_u64).unwrap_or(SHELL_DEFAULT_TIMEOUT_MS).min(SHELL_MAX_TIMEOUT_MS)
|
||
}
|
||
|
||
// ---------- computers ----------
|
||
// The reference assistant runs a bot's work on its own cloud box and only runs on the person's
|
||
// Mac when they turn on 在這台電腦上執行 (setting `local-execution`). Permission questions name
|
||
// the machine ("允許助理在 {targetMachineDisplay} 上使用 {app} 嗎?").
|
||
// Here the bot's own computer is a sealed folder, this Mac is the person's computer, and other
|
||
// computers are reached over SSH.
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub enum Place {
|
||
/// The bot's own computer when Docker isn't there: everything outside `root` in the home
|
||
/// folder is invisible.
|
||
Sealed { root: PathBuf },
|
||
/// The bot's own computer: AnyBot's Linux box (Docker), starting in `dir` inside it.
|
||
Box { dir: String },
|
||
/// The person's computer, starting in `workdir`.
|
||
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,
|
||
/// starting in `dir` (empty = its home).
|
||
Device { host: String, dir: String },
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub struct Target {
|
||
pub name: String,
|
||
pub place: Place,
|
||
}
|
||
|
||
/// Which of a bot's computers is which, by name (every computer goes by one plain name).
|
||
#[derive(Clone, Debug, Default)]
|
||
pub struct Roles {
|
||
/// The bot's own computer: 「Master 的電腦」.
|
||
pub own: String,
|
||
/// The computer the person is using now (where they typed to the bot), when known.
|
||
pub person: Option<String>,
|
||
/// This AnyBot runs on a server: the computer it is on is nobody's desk.
|
||
pub server: bool,
|
||
}
|
||
|
||
/// The tools with a `computer` choice added to each, listing the computers this bot can use.
|
||
pub fn specs_for(computers: &[String], default: &str) -> Vec<Value> {
|
||
let mut v = specs();
|
||
for t in v.iter_mut() {
|
||
// 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.");
|
||
t["parameters"]["properties"]["computer"] = json!({
|
||
"type": "string",
|
||
"enum": computers,
|
||
"description": desc
|
||
});
|
||
}
|
||
v
|
||
}
|
||
|
||
pub fn sh_quote(s: &str) -> String {
|
||
format!("'{}'", s.replace('\'', "'\\''"))
|
||
}
|
||
|
||
fn ssh_command(target: &str, dir: &str, cmd: &str) -> String {
|
||
let inner = if dir.trim().is_empty() { cmd.to_string() } else { format!("cd {} && {}", sh_quote(dir), cmd) };
|
||
format!("ssh -o BatchMode=yes -o ConnectTimeout=15 {} {}", sh_quote(target), sh_quote(&inner))
|
||
}
|
||
|
||
/// The local wrapper that runs `cmd` on a remote computer (an SSH host or the bot's box).
|
||
fn wrapper_for(place: &Place, cmd: &str, interactive: bool, job: &str) -> Option<String> {
|
||
match place {
|
||
Place::Ssh { target, dir } => Some(ssh_command(target, dir, cmd)),
|
||
Place::Box { dir } => Some(crate::cloudbox::exec_wrapper(dir, cmd, interactive, job)),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// On the bot's own box sudo is the bot's (the box gives it sudo without a password, and the
|
||
/// instructions say it may install packages): only what follows sudo is checked against the
|
||
/// always-denied list (`sudo rm -rf /` is still refused). It was refused outright, so the bot,
|
||
/// told it could sudo, worked around a block it couldn't see the reason for.
|
||
fn denied_on_box(cmd: &str) -> Option<Decision> {
|
||
for part in split_commands(cmd)? {
|
||
let mut rest = part.trim();
|
||
while let Some(after) = rest.strip_prefix("sudo").filter(|a| a.is_empty() || a.starts_with(char::is_whitespace)) {
|
||
rest = after.trim_start();
|
||
// sudo's own options (-n, -E, -H, -u root…) before the command.
|
||
while rest.starts_with('-') {
|
||
let (opt, more) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
|
||
rest = more.trim_start();
|
||
if matches!(opt, "-u" | "-g" | "-C" | "-D" | "-h" | "-p" | "-r" | "-t" | "-U") {
|
||
rest = rest.split_once(char::is_whitespace).map(|(_, m)| m.trim_start()).unwrap_or("");
|
||
}
|
||
}
|
||
}
|
||
if let Some(d) = DENIED_COMMANDS.iter().filter(|d| **d != "sudo").find(|d| starts_with_words(rest, d)) {
|
||
return Some(Decision::Deny(format!("「{d}」這類指令一律不允許。")));
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
pub fn decide_on(name: &str, args: &str, target: &Target, grants: &[String]) -> Decision {
|
||
let v = args_of(args);
|
||
let denied = |cmd: &str| -> Option<Decision> {
|
||
for part in split_commands(cmd)? {
|
||
if let Some(d) = DENIED_COMMANDS.iter().find(|d| starts_with_words(&part, d)) {
|
||
return Some(Decision::Deny(format!("「{d}」這類指令一律不允許。")));
|
||
}
|
||
}
|
||
None
|
||
};
|
||
match &target.place {
|
||
Place::Local { workdir } => decide(name, args, workdir, grants),
|
||
Place::Sealed { root } => {
|
||
if name == "shell" {
|
||
// Its own computer: the sandbox keeps it inside its folder. No sandbox, no command.
|
||
if !sealed_runs_commands() {
|
||
return Decision::Deny(NO_BOX_FOR_COMMANDS.into());
|
||
}
|
||
return denied(&arg_str(&v, "command")).unwrap_or(Decision::Allow);
|
||
}
|
||
if name == "read_terminal" || name == "write_terminal" {
|
||
return Decision::Allow;
|
||
}
|
||
let p = resolve(root, &arg_str(&v, "path"));
|
||
if !inside(root, &p) {
|
||
return Decision::Deny(format!("{} 看不到這台電腦上的其他檔案({})。要處理使用者的檔案,請在使用者的電腦上做(不確定是哪台就先問)。", target.name, p.display()));
|
||
}
|
||
Decision::Allow
|
||
}
|
||
// Its own computer: everything is allowed (it can be rebuilt), like the original's box.
|
||
Place::Box { .. } => {
|
||
if name == "shell" {
|
||
if let Some(d) = denied_on_box(&arg_str(&v, "command")) {
|
||
return d;
|
||
}
|
||
}
|
||
Decision::Allow
|
||
}
|
||
Place::Ssh { .. } | Place::Device { .. } => match name {
|
||
"list_dir" | "read_file" | "search" | "read_terminal" | "write_terminal" => Decision::Allow,
|
||
"edit_file" => {
|
||
if grants.iter().any(|g| g == "edit_file") {
|
||
Decision::Allow
|
||
} else {
|
||
Decision::Ask { reason: format!("修改 {} 上的檔案:{}", target.name, arg_str(&v, "path")), grant: "edit_file".into() }
|
||
}
|
||
}
|
||
_ => {
|
||
let cmd = arg_str(&v, "command");
|
||
match decide("shell", args, Path::new("/nonexistent-remote"), grants) {
|
||
Decision::Ask { grant, .. } => Decision::Ask { reason: format!("在 {} 上執行指令:{cmd}", target.name), grant },
|
||
other => other,
|
||
}
|
||
}
|
||
},
|
||
}
|
||
}
|
||
|
||
/// Run a remote command and give back its output (no background hand-off).
|
||
fn remote_capture(place: &Place, cmd: &str, stdin: Option<&[u8]>) -> Result<String, String> {
|
||
use std::io::Write;
|
||
let w = wrapper_for(place, cmd, stdin.is_some(), "").ok_or("不是遠端電腦")?;
|
||
let mut c = build_wrapper_command(&w, "", "");
|
||
let mut child = c.spawn().map_err(|e| format!("無法執行:{e}"))?;
|
||
if let Some(data) = stdin {
|
||
if let Some(mut si) = child.stdin.take() {
|
||
si.write_all(data).map_err(|e| format!("寫入失敗:{e}"))?;
|
||
}
|
||
} else {
|
||
drop(child.stdin.take());
|
||
}
|
||
let out = child.wait_with_output().map_err(|e| e.to_string())?;
|
||
let text = String::from_utf8_lossy(&out.stdout).to_string();
|
||
if out.status.success() {
|
||
Ok(text)
|
||
} else {
|
||
Err(format!("{}{}", String::from_utf8_lossy(&out.stderr).trim(), if text.trim().is_empty() { String::new() } else { format!("\n{}", text.trim()) }))
|
||
}
|
||
}
|
||
|
||
/// Run an allowed tool on a computer.
|
||
pub fn run_on(name: &str, args: &str, target: &Target, cancel: &AtomicBool, ids: &RunIds) -> (String, bool) {
|
||
let v = args_of(args);
|
||
let done = |r: Result<String, String>| match r {
|
||
Ok(s) => (s, true),
|
||
Err(e) => (e, false),
|
||
};
|
||
match &target.place {
|
||
Place::Local { workdir } => run(name, args, workdir, cancel, ids),
|
||
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" && !sealed_runs_commands() {
|
||
return (NO_BOX_FOR_COMMANDS.into(), false);
|
||
}
|
||
if name == "shell" {
|
||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/Users"));
|
||
return done(shell(&arg_str(&v, "command"), root, timeout_of(&v), cancel, ids, Some(&home)));
|
||
}
|
||
run(name, args, root, cancel, ids)
|
||
}
|
||
place @ (Place::Ssh { .. } | Place::Box { .. }) => {
|
||
if let Place::Box { .. } = place {
|
||
if let Err(e) = crate::cloudbox::ensure_running() {
|
||
return (e, false);
|
||
}
|
||
}
|
||
let _busy = crate::cloudbox::Busy::new(matches!(place, Place::Box { .. }));
|
||
let path = arg_str(&v, "path");
|
||
let p = if path.is_empty() { ".".to_string() } else { path.clone() };
|
||
if v.is_null() && !args.trim().is_empty() {
|
||
return ("參數不是有效的 JSON".into(), false);
|
||
}
|
||
// Box file tools stay inside this bot's own folder: every bot shares one container,
|
||
// one user and one disk, so `../` or an absolute path reached (and rewrote) the
|
||
// other bots' files — a planted .bashrc runs code on their next command.
|
||
let (p, path) = match place {
|
||
Place::Box { dir } if name != "shell" => match box_path(std::path::Path::new(dir), &p) {
|
||
Ok(s) => (s.clone(), s),
|
||
Err(e) => return (e, false),
|
||
},
|
||
_ => (p, path),
|
||
};
|
||
let remote = match name {
|
||
"shell" if arg_str(&v, "command").trim().is_empty() => return ("請給要執行的指令".into(), false),
|
||
"shell" => arg_str(&v, "command"),
|
||
"list_dir" => format!("ls -la {}", sh_quote(&p)),
|
||
"read_file" => {
|
||
let from = v.get("offset").and_then(Value::as_u64).unwrap_or(1).max(1);
|
||
let n = v.get("limit").and_then(Value::as_u64).unwrap_or(READ_DEFAULT_LINES as u64);
|
||
format!("test -f {q} || {{ echo '找不到檔案:'{q} >&2; exit 1; }}; sed -n '{from},{}p' {q} | cat -n", from + n - 1, q = sh_quote(&p))
|
||
}
|
||
"search" => {
|
||
let flag = if v.get("files_only").and_then(Value::as_bool).unwrap_or(false) { "l" } else { "n" };
|
||
format!("(command -v rg >/dev/null && rg -{flag} --no-heading -- {pat} {p} || grep -rI{flag} -- {pat} {p}) | head -n {SEARCH_CLIENT_LIMIT_LINES}", pat = sh_quote(&arg_str(&v, "pattern")), p = sh_quote(&p))
|
||
}
|
||
"edit_file" => {
|
||
if path.is_empty() {
|
||
return ("請給檔案路徑".into(), false);
|
||
}
|
||
let old = arg_str(&v, "old_text");
|
||
let new = arg_str(&v, "new_text");
|
||
let q = sh_quote(&path);
|
||
let content = if old.is_empty() {
|
||
new
|
||
} else {
|
||
let cur = match remote_capture(place, &format!("cat {q}"), None) {
|
||
Ok(c) => c,
|
||
Err(e) => return (format!("讀不到 {path}:{e}"), false),
|
||
};
|
||
match cur.matches(old.as_str()).count() {
|
||
0 => return ("找不到 old_text,檔案沒有改。請先讀檔確認原文。".into(), false),
|
||
1 => cur.replacen(old.as_str(), &new, 1),
|
||
n => return (format!("old_text 出現了 {n} 次,請多給一點前後文讓它只出現一次。"), false),
|
||
}
|
||
};
|
||
let write = format!("mkdir -p \"$(dirname {q})\" && cat > {q}");
|
||
return match remote_capture(place, &write, Some(content.as_bytes())) {
|
||
Ok(_) => (format!("已寫入 {path}({} 行)。", content.lines().count()), true),
|
||
Err(e) => (format!("寫不進 {path}:{e}"), false),
|
||
};
|
||
}
|
||
"read_terminal" | "write_terminal" => return run(name, args, Path::new("/"), cancel, ids),
|
||
other => return (format!("沒有「{other}」這個工具"), false),
|
||
};
|
||
// Tag the in-container command so stopping it here also stops it inside the box
|
||
// (killing the local docker client alone left the program running in the box).
|
||
let job = crate::httpd::random_hex(8);
|
||
let wrapper = wrapper_for(place, &remote, name == "shell", &job).unwrap_or_default();
|
||
let kill = matches!(place, Place::Box { .. }).then(|| crate::cloudbox::exec_kill_argv(&job));
|
||
done(shell_wrapped(&wrapper, &remote, timeout_of(&v), cancel, ids, kill))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Keep a Box file path inside the bot's own folder: relative paths join the bot's dir, and
|
||
/// a normalised result that lands outside it (`../`, absolute paths to other bots' folders)
|
||
/// is refused. All bots share one container and one user, so the folder is the only wall.
|
||
pub fn box_path(dir: &Path, p: &str) -> Result<String, String> {
|
||
let joined = if p.starts_with('/') { PathBuf::from(p) } else { dir.join(p) };
|
||
let mut parts: Vec<std::ffi::OsString> = vec![];
|
||
for c in joined.components() {
|
||
use std::path::Component::*;
|
||
match c {
|
||
ParentDir => {
|
||
parts.pop();
|
||
}
|
||
CurDir => {}
|
||
RootDir => parts.push(std::ffi::OsString::from("/")),
|
||
Normal(x) => parts.push(x.to_os_string()),
|
||
Prefix(x) => parts.push(x.as_os_str().to_os_string()),
|
||
}
|
||
}
|
||
let mut norm = PathBuf::new();
|
||
for x in parts {
|
||
norm.push(x);
|
||
}
|
||
if norm.starts_with(dir) {
|
||
Ok(norm.display().to_string())
|
||
} else {
|
||
Err(format!("這個檔案不在你的資料夾裡(Bot 的電腦上每個 Bot 只用自己的資料夾):{p}"))
|
||
}
|
||
}
|
||
|
||
/// Run a tool that the rules already allowed. Returns (output, ok).
|
||
pub fn run(name: &str, args: &str, workdir: &Path, cancel: &AtomicBool, ids: &RunIds) -> (String, bool) {
|
||
let v = args_of(args);
|
||
if v.is_null() && !args.trim().is_empty() {
|
||
return ("參數不是有效的 JSON".into(), false);
|
||
}
|
||
let _ = std::fs::create_dir_all(workdir);
|
||
let r = match name {
|
||
"list_dir" => list_dir(&resolve(workdir, &arg_str(&v, "path"))),
|
||
"search" => search(&arg_str(&v, "pattern"), &resolve(workdir, &arg_str(&v, "path")), v.get("files_only").and_then(Value::as_bool).unwrap_or(false)),
|
||
"read_file" => read_file(
|
||
&resolve(workdir, &arg_str(&v, "path")),
|
||
v.get("offset").and_then(Value::as_u64).unwrap_or(1).max(1) as usize,
|
||
v.get("limit").and_then(Value::as_u64).map(|l| l as usize).unwrap_or(READ_DEFAULT_LINES),
|
||
),
|
||
"edit_file" => edit_file(&resolve(workdir, &arg_str(&v, "path")), &arg_str(&v, "old_text"), &arg_str(&v, "new_text")),
|
||
"shell" => shell(&arg_str(&v, "command"), workdir, timeout_of(&v), cancel, ids, None),
|
||
"read_terminal" => read_terminal(v.get("id").and_then(Value::as_u64).unwrap_or(0)),
|
||
"write_terminal" => write_terminal(v.get("id").and_then(Value::as_u64).unwrap_or(0), &arg_str(&v, "input"), v.get("close").and_then(Value::as_bool).unwrap_or(false)),
|
||
other => Err(format!("沒有「{other}」這個工具")),
|
||
};
|
||
match r {
|
||
Ok(s) => (s, true),
|
||
Err(e) => (e, false),
|
||
}
|
||
}
|
||
|
||
fn list_dir(p: &Path) -> Result<String, String> {
|
||
let rd = std::fs::read_dir(p).map_err(|e| format!("無法開啟資料夾 {}:{e}", p.display()))?;
|
||
let mut rows: Vec<(bool, String, u64)> = rd
|
||
.filter_map(|e| e.ok())
|
||
.filter(|e| !is_ignored(&e.path()))
|
||
.map(|e| {
|
||
let md = e.metadata().ok();
|
||
(md.as_ref().map(|m| m.is_dir()).unwrap_or(false), e.file_name().to_string_lossy().to_string(), md.map(|m| m.len()).unwrap_or(0))
|
||
})
|
||
.collect();
|
||
rows.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.to_lowercase().cmp(&b.1.to_lowercase())));
|
||
let total = rows.len();
|
||
let mut out: Vec<String> = rows.into_iter().take(SEARCH_CLIENT_LIMIT_LINES).map(|(d, n, s)| if d { format!("{n}/") } else { format!("{n} {}", human_size(s)) }).collect();
|
||
if total > SEARCH_CLIENT_LIMIT_LINES {
|
||
out.push(format!("…還有 {} 項", total - SEARCH_CLIENT_LIMIT_LINES));
|
||
}
|
||
if out.is_empty() {
|
||
out.push("(空資料夾)".into());
|
||
}
|
||
Ok(format!("{}\n{}", p.display(), out.join("\n")))
|
||
}
|
||
|
||
fn human_size(b: u64) -> String {
|
||
match b {
|
||
0..=1023 => format!("{b} B"),
|
||
1024..=1_048_575 => format!("{:.1} KB", b as f64 / 1024.0),
|
||
_ => format!("{:.1} MB", b as f64 / 1_048_576.0),
|
||
}
|
||
}
|
||
|
||
fn search(pattern: &str, root: &Path, files_only: bool) -> Result<String, String> {
|
||
if pattern.is_empty() {
|
||
return Err("請給要搜尋的文字".into());
|
||
}
|
||
let deadline = Instant::now() + Duration::from_millis(SEARCH_TIMEOUT_MS);
|
||
let mut lines: Vec<String> = vec![];
|
||
let mut timed_out = false;
|
||
// Prefer ripgrep when it's installed; fall back to a plain walk with substring matching.
|
||
let rg = Command::new("rg")
|
||
.args(["--no-heading", "--line-number", "--color", "never", "--max-columns", "300", "-g", "!.git", "-g", "!.env*", "-g", "!*.pem", "-g", "!*.key"])
|
||
.args(if files_only { vec!["-l"] } else { vec![] })
|
||
.arg("--")
|
||
.arg(pattern)
|
||
.arg(root)
|
||
.stdout(Stdio::piped())
|
||
.stderr(Stdio::null())
|
||
.spawn();
|
||
if let Ok(mut child) = rg {
|
||
let mut out = child.stdout.take().unwrap();
|
||
let buf = Arc::new(Mutex::new(Vec::new()));
|
||
let b2 = buf.clone();
|
||
let reader = std::thread::spawn(move || {
|
||
let mut chunk = [0u8; 8192];
|
||
while let Ok(n) = out.read(&mut chunk) {
|
||
if n == 0 {
|
||
break;
|
||
}
|
||
let mut b = b2.lock().unwrap();
|
||
b.extend_from_slice(&chunk[..n]);
|
||
if b.iter().filter(|c| **c == b'\n').count() > SEARCH_HARD_MAX_LINES {
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
loop {
|
||
if child.try_wait().ok().flatten().is_some() || reader.is_finished() {
|
||
break;
|
||
}
|
||
if Instant::now() > deadline {
|
||
timed_out = true;
|
||
let _ = child.kill();
|
||
break;
|
||
}
|
||
std::thread::sleep(Duration::from_millis(20));
|
||
}
|
||
let _ = child.kill();
|
||
let _ = reader.join();
|
||
let text = String::from_utf8_lossy(&buf.lock().unwrap()).to_string();
|
||
lines = text.lines().map(|l| l.to_string()).collect();
|
||
} else {
|
||
let needle = pattern.to_lowercase();
|
||
let mut stack = vec![root.to_path_buf()];
|
||
'walk: while let Some(p) = stack.pop() {
|
||
if Instant::now() > deadline {
|
||
timed_out = true;
|
||
break;
|
||
}
|
||
if is_ignored(&p) {
|
||
continue;
|
||
}
|
||
if p.is_dir() {
|
||
if let Ok(rd) = std::fs::read_dir(&p) {
|
||
stack.extend(rd.filter_map(|e| e.ok()).map(|e| e.path()).filter(|q| q.file_name().map(|n| n != "node_modules" && n != "target").unwrap_or(true)));
|
||
}
|
||
continue;
|
||
}
|
||
let Ok(text) = std::fs::read_to_string(&p) else { continue };
|
||
for (i, l) in text.lines().enumerate() {
|
||
if l.to_lowercase().contains(&needle) {
|
||
lines.push(if files_only { p.display().to_string() } else { format!("{}:{}:{}", p.display(), i + 1, l.chars().take(300).collect::<String>()) });
|
||
if files_only {
|
||
break;
|
||
}
|
||
if lines.len() >= SEARCH_HARD_MAX_LINES {
|
||
break 'walk;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let total = lines.len();
|
||
let mut out: Vec<String> = lines.into_iter().take(SEARCH_CLIENT_LIMIT_LINES).collect();
|
||
if total > SEARCH_CLIENT_LIMIT_LINES {
|
||
out.push(format!("…結果太多,只列出前 {SEARCH_CLIENT_LIMIT_LINES} 行(共 {total} 行以上)"));
|
||
}
|
||
if timed_out {
|
||
out.push(format!("…搜尋超過 {} 秒,結果可能不完整", SEARCH_TIMEOUT_MS / 1000));
|
||
}
|
||
if out.is_empty() {
|
||
out.push("沒有找到".into());
|
||
}
|
||
Ok(out.join("\n"))
|
||
}
|
||
|
||
fn read_file(p: &Path, offset: usize, limit: usize) -> Result<String, String> {
|
||
let md = std::fs::metadata(p).map_err(|e| format!("找不到 {}:{e}", p.display()))?;
|
||
if md.is_dir() {
|
||
return Err(format!("{} 是資料夾,請用 list_dir", p.display()));
|
||
}
|
||
if md.len() > READ_MAX_BYTES {
|
||
return Err(format!("{} 太大了({}),最多讀 8 MB", p.display(), human_size(md.len())));
|
||
}
|
||
let bytes = std::fs::read(p).map_err(|e| format!("無法讀取 {}:{e}", p.display()))?;
|
||
if bytes.iter().take(8000).any(|b| *b == 0) {
|
||
return Err(format!("{} 不是文字檔({})", p.display(), human_size(md.len())));
|
||
}
|
||
let text = String::from_utf8_lossy(&bytes);
|
||
let all: Vec<&str> = text.lines().collect();
|
||
let end = (offset - 1 + limit).min(all.len());
|
||
let mut out: Vec<String> = all.iter().enumerate().skip(offset - 1).take(end.saturating_sub(offset - 1)).map(|(i, l)| format!("{:>5} {l}", i + 1)).collect();
|
||
if end < all.len() {
|
||
out.push(format!("…還有 {} 行(用 offset={} 繼續讀)", all.len() - end, end + 1));
|
||
}
|
||
if out.is_empty() {
|
||
out.push("(空檔案)".into());
|
||
}
|
||
Ok(out.join("\n"))
|
||
}
|
||
|
||
fn edit_file(p: &Path, old: &str, new: &str) -> Result<String, String> {
|
||
if old.is_empty() {
|
||
if let Some(dir) = p.parent() {
|
||
std::fs::create_dir_all(dir).map_err(|e| format!("無法建立資料夾:{e}"))?;
|
||
}
|
||
let existed = p.exists();
|
||
std::fs::write(p, new).map_err(|e| format!("無法寫入 {}:{e}", p.display()))?;
|
||
return Ok(format!("{} {}({} 行)", if existed { "已覆寫" } else { "已建立" }, p.display(), new.lines().count()));
|
||
}
|
||
let text = std::fs::read_to_string(p).map_err(|e| format!("無法讀取 {}:{e}", p.display()))?;
|
||
match text.matches(old).count() {
|
||
0 => Err(format!("在 {} 裡找不到要替換的文字", p.display())),
|
||
1 => {
|
||
std::fs::write(p, text.replacen(old, new, 1)).map_err(|e| format!("無法寫入 {}:{e}", p.display()))?;
|
||
Ok(format!("已修改 {}", p.display()))
|
||
}
|
||
n => Err(format!("要替換的文字在 {} 出現了 {n} 次,請多給一些前後文讓它只出現一次", p.display())),
|
||
}
|
||
}
|
||
|
||
fn shell(cmd: &str, workdir: &Path, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds, seal: Option<&Path>) -> Result<String, String> {
|
||
let r = run_command(build_command_sealed(cmd, workdir, &protected_dirs(), &readable_dirs(), seal, &ids.conversation, &ids.request), cmd, timeout_ms, cancel, ids, None);
|
||
// (The bot's own sealed computer keeps every command in the sandbox: no hints about outside.)
|
||
let note = |out: &str| sandbox_note(cmd, out).filter(|_| seal.is_none()).map(|n| format!("{out}\n{n}")).unwrap_or_else(|| out.to_string());
|
||
match r {
|
||
Ok(out) => Ok(note(&out)),
|
||
Err(out) => Err(note(&out)),
|
||
}
|
||
}
|
||
|
||
/// Run a wrapper command (ssh / docker exec) the same way: background hand-off, stop, output.
|
||
pub fn shell_wrapped(wrapper: &str, shown: &str, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds, kill: Option<Vec<String>>) -> Result<String, String> {
|
||
run_command(build_wrapper_command(wrapper, &ids.conversation, &ids.request), shown, timeout_ms, cancel, ids, kill)
|
||
}
|
||
|
||
fn run_command(mut command: Command, cmd: &str, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds, kill: Option<Vec<String>>) -> Result<String, String> {
|
||
let mut child = command.spawn().map_err(|e| format!("無法執行:{e}"))?;
|
||
let stdin = Arc::new(Mutex::new(child.stdin.take()));
|
||
let root = child.id();
|
||
let output = Arc::new(Mutex::new(String::new()));
|
||
for stream in [child.stdout.take().map(|s| Box::new(s) as Box<dyn Read + Send>), child.stderr.take().map(|s| Box::new(s) as Box<dyn Read + Send>)].into_iter().flatten() {
|
||
let out = output.clone();
|
||
std::thread::spawn(move || {
|
||
let mut stream = stream;
|
||
let mut chunk = [0u8; 4096];
|
||
while let Ok(n) = stream.read(&mut chunk) {
|
||
if n == 0 {
|
||
break;
|
||
}
|
||
// Cap the kept output (keep the newest): `yes` must not eat all the RAM.
|
||
let mut out = out.lock().unwrap();
|
||
if out.len() < 4_000_000 {
|
||
out.push_str(&String::from_utf8_lossy(&chunk[..n]));
|
||
}
|
||
}
|
||
});
|
||
}
|
||
let start = Instant::now();
|
||
loop {
|
||
if let Ok(Some(status)) = child.try_wait() {
|
||
std::thread::sleep(Duration::from_millis(30));
|
||
let out = head_tail(&output.lock().unwrap());
|
||
let code = status.code().unwrap_or(-1);
|
||
return if code == 0 { Ok(if out.trim().is_empty() { "(完成,沒有輸出)".into() } else { out }) } else { Err(format!("結束代碼 {code}\n{out}")) };
|
||
}
|
||
if cancel.load(Ordering::Relaxed) {
|
||
kill_tree(root);
|
||
let _ = child.kill();
|
||
// Reap it: a killed child nobody waits for stays a zombie for the app's lifetime.
|
||
let _ = child.wait();
|
||
// The remote end (a program running inside the box) is not in this process tree.
|
||
if let Some(k) = &kill {
|
||
if let Some((head, rest)) = k.split_first() {
|
||
let _ = Command::new(head).args(rest).status();
|
||
}
|
||
}
|
||
return Err("已停止(連同它啟動的程式)".into());
|
||
}
|
||
if start.elapsed() >= Duration::from_millis(timeout_ms) {
|
||
// Hand off to the background instead of killing it.
|
||
let id = next_terminal_id();
|
||
let child = Arc::new(Mutex::new(child));
|
||
let exit = Arc::new(Mutex::new(None));
|
||
let (c2, e2) = (child.clone(), exit.clone());
|
||
std::thread::spawn(move || loop {
|
||
if let Ok(Some(st)) = c2.lock().unwrap().try_wait() {
|
||
*e2.lock().unwrap() = Some(st.code().unwrap_or(-1));
|
||
break;
|
||
}
|
||
std::thread::sleep(Duration::from_millis(200));
|
||
});
|
||
let so_far = head_tail(&output.lock().unwrap());
|
||
terminals().lock().unwrap().insert(id, Terminal { command: cmd.to_string(), stdin, output, child, exit, root, conversation: ids.conversation.clone() });
|
||
return Ok(format!("指令跑了超過 {} 秒,已移到背景繼續執行(背景指令 #{id})。用 read_terminal 查看進度。\n目前的輸出:\n{so_far}", timeout_ms / 1000));
|
||
}
|
||
std::thread::sleep(Duration::from_millis(20));
|
||
}
|
||
}
|
||
|
||
fn read_terminal(id: u64) -> Result<String, String> {
|
||
let t = terminals().lock().unwrap();
|
||
let term = t.get(&id).ok_or_else(|| format!("沒有背景指令 #{id}"))?;
|
||
let out = head_tail(&term.output.lock().unwrap());
|
||
let state = match *term.exit.lock().unwrap() {
|
||
Some(c) => format!("已結束(代碼 {c})"),
|
||
None => {
|
||
let (n, cpu, mem) = tree_stats(term.root);
|
||
format!("還在執行:{n} 個程式,CPU 時間 {:.1} 秒,記憶體 {mem} MB", cpu as f64 / 1000.0)
|
||
}
|
||
};
|
||
Ok(format!("背景指令 #{id}:{}\n狀態:{state}\n{out}", term.command))
|
||
}
|
||
|
||
/// Send input to a background command (like typing into its terminal). `close` ends its input.
|
||
fn write_terminal(id: u64, input: &str, close: bool) -> Result<String, String> {
|
||
use std::io::Write;
|
||
// Copy the terminal's own handles out first: writing to a pipe whose buffer is full blocks,
|
||
// and holding the shared registry lock while blocked stops every other terminal tool.
|
||
let (stdin, exit) = {
|
||
let t = terminals().lock().unwrap();
|
||
let term = t.get(&id).ok_or_else(|| format!("沒有背景指令 #{id}"))?;
|
||
(term.stdin.clone(), term.exit.clone())
|
||
};
|
||
if exit.lock().unwrap().is_some() {
|
||
return Err(format!("背景指令 #{id} 已經結束了"));
|
||
}
|
||
let mut slot = stdin.lock().unwrap();
|
||
if !input.is_empty() {
|
||
let w = slot.as_mut().ok_or("這個指令的輸入已經關閉了")?;
|
||
w.write_all(input.as_bytes()).and_then(|_| w.flush()).map_err(|e| format!("送不進去:{e}"))?;
|
||
}
|
||
if close {
|
||
*slot = None;
|
||
}
|
||
drop(slot);
|
||
std::thread::sleep(Duration::from_millis(400));
|
||
let out = read_terminal(id)?;
|
||
Ok(format!("已送出{}{}。\n{out}", if input.is_empty() { String::new() } else { format!(" {} 個字", input.chars().count()) }, if close { ",並結束輸入" } else { "" }))
|
||
}
|
||
|
||
/// Stop every background command and what it started (when the app quits).
|
||
pub fn stop_all() {
|
||
for t in terminals().lock().unwrap().values() {
|
||
// child before exit — the watcher thread takes them in this order; the reverse order
|
||
// here could wedge both threads forever (AB-BA).
|
||
let mut child = t.child.lock().unwrap();
|
||
if t.exit.lock().unwrap().is_none() {
|
||
kill_tree(t.root);
|
||
let _ = child.kill();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Stop one bot's background commands (and what they started). Returns how many were running.
|
||
pub fn stop_for(conversation: &str) -> usize {
|
||
let mut n = 0;
|
||
for t in terminals().lock().unwrap().values().filter(|t| t.conversation == conversation) {
|
||
// Same lock order as the watcher thread (child, then exit).
|
||
let mut child = t.child.lock().unwrap();
|
||
if t.exit.lock().unwrap().is_none() {
|
||
kill_tree(t.root);
|
||
let _ = child.kill();
|
||
n += 1;
|
||
}
|
||
}
|
||
n
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn wd() -> PathBuf {
|
||
let d = std::env::temp_dir().join(format!("anybot-test-{}", std::process::id()));
|
||
std::fs::create_dir_all(&d).unwrap();
|
||
d
|
||
}
|
||
|
||
#[test]
|
||
fn splits_chains() {
|
||
assert_eq!(split_commands("ls -la && git status | head; echo 'a;b'").unwrap(), vec!["ls -la", "git status", "head", "echo 'a;b'"]);
|
||
assert!(split_commands("echo $(whoami)").is_none());
|
||
assert!(split_commands("eval ls").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn layered_rules() {
|
||
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(_)));
|
||
// 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 { .. }));
|
||
assert_eq!(decide("read_file", r#"{"path":"a.txt"}"#, &w, &[]), Decision::Allow);
|
||
assert!(matches!(decide("read_file", r#"{"path":"/etc/hosts"}"#, &w, &[]), Decision::Ask { .. }));
|
||
assert!(matches!(decide("read_file", r#"{"path":"../../.ssh/id_rsa"}"#, &w, &[]), Decision::Deny(_) | Decision::Ask { .. }));
|
||
assert!(matches!(decide("edit_file", r#"{"path":"a.txt","new_text":"x"}"#, &w, &[]), Decision::Ask { .. }));
|
||
assert_eq!(decide("edit_file", r#"{"path":"a.txt","new_text":"x"}"#, &w, &["edit_file".into()]), Decision::Allow);
|
||
}
|
||
|
||
#[test]
|
||
fn edit_read_search() {
|
||
let w = wd();
|
||
let c = AtomicBool::new(false);
|
||
assert!(run("edit_file", r#"{"path":"n/a.txt","new_text":"hello\nworld\n"}"#, &w, &c, &RunIds::default()).1);
|
||
assert!(run("edit_file", r#"{"path":"n/a.txt","old_text":"world","new_text":"there"}"#, &w, &c, &RunIds::default()).1);
|
||
let (r, ok) = run("read_file", r#"{"path":"n/a.txt"}"#, &w, &c, &RunIds::default());
|
||
assert!(ok && r.contains("there"), "{r}");
|
||
let (s, ok) = run("search", r#"{"pattern":"there"}"#, &w, &c, &RunIds::default());
|
||
assert!(ok && s.contains("a.txt"), "{s}");
|
||
assert!(!run("edit_file", r#"{"path":"n/a.txt","old_text":"missing","new_text":"x"}"#, &w, &c, &RunIds::default()).1);
|
||
}
|
||
|
||
#[test]
|
||
fn shell_moves_to_background() {
|
||
let w = wd();
|
||
let c = AtomicBool::new(false);
|
||
let (r, ok) = run("shell", r#"{"command":"echo start; sleep 1; echo end","timeout_ms":200}"#, &w, &c, &RunIds::default());
|
||
assert!(ok && r.contains("背景指令 #"), "{r}");
|
||
let id: u64 = r.split("背景指令 #").nth(1).unwrap().split(')').next().unwrap().parse().unwrap();
|
||
std::thread::sleep(Duration::from_millis(1500));
|
||
let (t, _) = run("read_terminal", &format!(r#"{{"id":{id}}}"#), &w, &c, &RunIds::default());
|
||
assert!(t.contains("end") && t.contains("已結束"), "{t}");
|
||
let (q, ok) = run("shell", r#"{"command":"echo quick"}"#, &w, &c, &RunIds::default());
|
||
assert!(ok && q.contains("quick"));
|
||
}
|
||
|
||
#[test]
|
||
fn sandbox_breakers_are_recognized_and_nothing_else() {
|
||
assert!(process_listing("ps aux"));
|
||
assert!(process_listing("ps -ax | grep -i tailscale | grep -v grep"));
|
||
assert!(process_listing("/bin/ps -o pid,comm | awk '{print $1}' | head -5"));
|
||
assert!(process_listing("top -l 1 -n 0"));
|
||
assert!(!process_listing("ps aux > /tmp/x"));
|
||
assert!(!process_listing("ps aux | grep x ~/.ssh/id_rsa"));
|
||
assert!(!process_listing("ps aux | xargs cat"));
|
||
assert!(!process_listing("ps aux; cat ~/.ssh/id_rsa"));
|
||
assert!(!process_listing("ps $(cat /etc/passwd)"));
|
||
assert!(!process_listing("pss aux"));
|
||
assert_eq!(plain_program("/Applications/Tailscale.app/Contents/MacOS/Tailscale status").as_deref(), Some("/Applications/Tailscale.app/Contents/MacOS/Tailscale"));
|
||
assert_eq!(plain_program("\"/Applications/My App.app/Contents/MacOS/My App\" --help").as_deref(), Some("/Applications/My App.app/Contents/MacOS/My App"));
|
||
assert_eq!(plain_program("/Applications/T.app/Contents/MacOS/T status; cat ~/.ssh/id_rsa"), None);
|
||
assert_eq!(plain_program("/Applications/T.app/Contents/MacOS/T status | tee x"), None);
|
||
assert!(!app_sandboxed_cli("/usr/bin/osascript"));
|
||
assert!(!app_sandboxed_cli("/Applications/../usr/bin/osascript"));
|
||
assert!(!app_sandboxed_cli("/Applications/Nope.app/Contents/MacOS/Nope"));
|
||
assert!(!runs_outside_sandbox("osascript -e 'do shell script \"cat ~/.ssh/id_rsa\"'"));
|
||
assert!(!runs_outside_sandbox("cat ~/.ssh/id_rsa"));
|
||
}
|
||
|
||
#[test]
|
||
fn sudo_is_the_bots_on_its_own_box_but_what_follows_is_still_checked() {
|
||
let b = Target { name: "Bot 的電腦".into(), place: Place::Box { dir: "/home/bot/bot-1".into() } };
|
||
let d = |c: &str| decide_on("shell", &json!({"command": c}).to_string(), &b, &[]);
|
||
for ok in ["sudo apt-get install -y tree", "sudo ln -sf /usr/share/zoneinfo/Asia/Tokyo /etc/localtime && echo Asia/Tokyo | sudo tee /etc/timezone", "sudo -n -u root true", "ls; sudo -E make install"] {
|
||
assert!(matches!(d(ok), Decision::Allow), "{ok}");
|
||
}
|
||
for bad in ["sudo rm -rf /", "sudo -u root mkfs /dev/sda", "rm -rf ~", "sudo sudo dd if=/dev/zero of=/dev/sda", "su -c id", "sudo su"] {
|
||
assert!(matches!(d(bad), Decision::Deny(_)), "{bad}");
|
||
}
|
||
// The person's own computer is unchanged: sudo stays refused there.
|
||
let w = std::env::temp_dir();
|
||
assert!(matches!(decide("shell", r#"{"command":"sudo ls"}"#, &w, &[]), Decision::Deny(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn applescript_runs_outside_only_when_plain_and_harmless() {
|
||
assert_eq!(plain_words(r#"osascript -e 'tell application "Tailscale" to quit' -e "set x to \"a\" & \"b\"""#).unwrap(), vec!["osascript", "-e", r#"tell application "Tailscale" to quit"#, "-e", r#"set x to "a" & "b""#]);
|
||
assert!(plain_words("osascript -e 'x'; cat ~/.ssh/id_rsa").is_none());
|
||
assert!(plain_words("osascript -e \"$(cat ~/.ssh/id_rsa)\"").is_none());
|
||
assert!(plain_words("osascript <<EOF\nx\nEOF").is_none());
|
||
assert!(plain_words("osascript -e 'x' > /tmp/o").is_none());
|
||
assert!(osascript_outside(r#"osascript -e 'tell application "System Events" to get name of first process whose frontmost is true'"#).is_ok());
|
||
assert!(osascript_outside(r#"osascript -e 'tell application "Tailscale" to activate' -e 'delay 1'"#).is_ok());
|
||
for bad in [
|
||
r#"osascript -e 'do shell script "cat ~/.ssh/id_rsa"'"#,
|
||
r#"osascript -e 'tell application "Terminal" to do script "id"'"#,
|
||
r#"osascript -e 'tell application "System Events" to keystroke "rm -rf ~"'"#,
|
||
r#"osascript -e 'read POSIX file "/Users/x/.aws/credentials"'"#,
|
||
r#"osascript -l JavaScript -e 'ObjC.import("Foundation")'"#,
|
||
"osascript ~/evil.scpt",
|
||
"osascript -i",
|
||
"osascript",
|
||
] {
|
||
assert!(osascript_outside(bad).is_err(), "{bad}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_command_the_sandbox_broke_says_so() {
|
||
if sandbox_exec().is_none() {
|
||
return;
|
||
}
|
||
assert!(sandbox_note("ps aux; echo", "/bin/sh: /bin/ps: Operation not permitted").unwrap().contains("pgrep"));
|
||
let err = "40:85: execution error: System Events發生錯誤:發生越權取用的錯誤。 (-10004)";
|
||
// Joined to another command, it stayed in the sandbox: say how to write it.
|
||
assert!(sandbox_note("osascript -e 'tell application \"System Events\" to …'; echo done", err).unwrap().contains("單獨一條 osascript"));
|
||
// Plain and harmless, it ran outside: a -10004 then is macOS's own answer, not the sandbox.
|
||
assert_eq!(sandbox_note("osascript -e 'tell application \"System Events\" to …'", err), None);
|
||
assert!(sandbox_note("/Applications/T.app/Contents/MacOS/T status && echo", "/bin/sh: line 1: 9823 Trace/BPT trap: 5").unwrap().contains("單獨執行"));
|
||
assert_eq!(sandbox_note("ls", "No such file or directory"), None);
|
||
}
|
||
|
||
/// Plain AppleScript drives another app (outside the sandbox); joined to another command it
|
||
/// stays in the sandbox and gets -10004 with a note. On a Mac with 自動化 allowed for the
|
||
/// test runner: `cargo test applescript_drives_apps -- --ignored --nocapture`.
|
||
#[test]
|
||
#[ignore]
|
||
fn applescript_drives_apps_outside_the_sandbox() {
|
||
let w = std::env::temp_dir();
|
||
let ask = r#"osascript -e 'tell application "System Events" to get name of first process whose frontmost is true'"#;
|
||
let out = build_command_sealed(ask, &w, &protected_dirs(), &readable_dirs(), None, "1", "r").output().unwrap();
|
||
println!("plain: {:?} {}", out.status, String::from_utf8_lossy(&out.stdout).trim());
|
||
assert!(out.status.success() && !String::from_utf8_lossy(&out.stdout).trim().is_empty(), "{out:?}");
|
||
let joined = format!("{ask}; true");
|
||
let out = build_command_sealed(&joined, &w, &protected_dirs(), &readable_dirs(), None, "1", "r").output().unwrap();
|
||
let text = String::from_utf8_lossy(&out.stderr).to_string();
|
||
println!("joined: {text}");
|
||
assert!(text.contains("-10004"));
|
||
assert!(sandbox_note(&joined, &text).unwrap().contains("單獨一條 osascript"));
|
||
}
|
||
|
||
/// An App Store app's command line runs outside AnyBot's sandbox and works; inside it dies.
|
||
/// Needs Tailscale from the App Store: `cargo test app_store_cli_runs -- --ignored`.
|
||
#[test]
|
||
#[ignore]
|
||
fn app_store_cli_runs_outside_the_sandbox() {
|
||
let t = "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
|
||
assert!(runs_outside_sandbox(&format!("{t} version")));
|
||
let w = std::env::temp_dir();
|
||
let out = build_command_sealed(&format!("{t} version"), &w, &protected_dirs(), &readable_dirs(), None, "1", "r").output().unwrap();
|
||
assert!(String::from_utf8_lossy(&out.stdout).trim().chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false), "{out:?}");
|
||
let ps = build_command_sealed("ps -ax | grep -c .", &w, &protected_dirs(), &readable_dirs(), None, "1", "r").output().unwrap();
|
||
assert!(ps.status.success(), "{ps:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn head_tail_trims_middle() {
|
||
let s = "a".repeat(OUTPUT_HEAD_CHARS) + &"b".repeat(5000) + &"c".repeat(OUTPUT_TAIL_CHARS);
|
||
let t = head_tail(&s);
|
||
assert!(t.contains("中間省略 5000 個字"));
|
||
}
|
||
|
||
#[test]
|
||
fn cpu_time_and_tree_walk() {
|
||
assert_eq!(parse_cpu_time("0:01.50"), 1500);
|
||
assert_eq!(parse_cpu_time("01:02:03"), 3_723_000);
|
||
assert_eq!(parse_cpu_time("1-00:00:00"), 86_400_000);
|
||
let t = [
|
||
ProcRow { pid: 10, ppid: 1, rss_kb: 2048, cpu_ms: 5 },
|
||
ProcRow { pid: 11, ppid: 10, rss_kb: 1024, cpu_ms: 7 },
|
||
ProcRow { pid: 12, ppid: 11, rss_kb: 0, cpu_ms: 0 },
|
||
ProcRow { pid: 13, ppid: 1, rss_kb: 0, cpu_ms: 0 },
|
||
];
|
||
assert_eq!(descendants(&t, &[10]), vec![10, 11, 12]);
|
||
assert_eq!(descendants(&t, &[10, 11]), vec![10, 11, 12]);
|
||
}
|
||
|
||
#[test]
|
||
fn commands_are_tagged_and_keys_hidden() {
|
||
let w = wd();
|
||
let ids = RunIds { conversation: "7".into(), request: "call_x".into() };
|
||
let (r, ok) = run("shell", r#"{"command":"echo $ANYBOT_CONVERSATION_ID/$ANYBOT_REQUEST_ID/${OPENAI_API_KEY:-none}"}"#, &w, &AtomicBool::new(false), &ids);
|
||
assert!(ok && r.contains("7/call_x/none"), "{r}");
|
||
}
|
||
|
||
#[test]
|
||
fn sandbox_blocks_protected_folders() {
|
||
if sandbox_exec().is_none() {
|
||
return;
|
||
}
|
||
let w = wd();
|
||
let secret = std::env::temp_dir().join(format!("anybot-secret-{}", std::process::id()));
|
||
std::fs::create_dir_all(&secret).unwrap();
|
||
std::fs::write(secret.join("k.txt"), "top-secret").unwrap();
|
||
let out = build_command(&format!("cat '{}/k.txt'", secret.display()), &w, std::slice::from_ref(&secret), "1", "r").output().unwrap();
|
||
assert!(!String::from_utf8_lossy(&out.stdout).contains("top-secret"));
|
||
let out = build_command(&format!("cat '{}/k.txt'", secret.display()), &w, &[], "1", "r").output().unwrap();
|
||
assert!(String::from_utf8_lossy(&out.stdout).contains("top-secret"));
|
||
// The chat's attachments inside it: readable, not writable; the keys next to them: hidden.
|
||
let att = secret.join("attachments");
|
||
std::fs::create_dir_all(&att).unwrap();
|
||
std::fs::write(att.join("photo.txt"), "the-photo").unwrap();
|
||
let run = |cmd: String| build_command_sealed(&cmd, &w, std::slice::from_ref(&secret), std::slice::from_ref(&att), None, "1", "r").output().unwrap();
|
||
assert!(String::from_utf8_lossy(&run(format!("cat '{}/photo.txt'", att.display())).stdout).contains("the-photo"));
|
||
assert!(!String::from_utf8_lossy(&run(format!("cat '{}/k.txt'", secret.display())).stdout).contains("top-secret"));
|
||
run(format!("echo changed > '{}/photo.txt'", att.display()));
|
||
assert_eq!(std::fs::read_to_string(att.join("photo.txt")).unwrap(), "the-photo");
|
||
let _ = std::fs::remove_dir_all(&secret);
|
||
}
|
||
|
||
#[test]
|
||
fn bot_computer_is_sealed() {
|
||
if sandbox_exec().is_none() {
|
||
// No sandbox (Linux, Windows): no command runs there at all, asked or not.
|
||
let t = Target { name: crate::model::BOT_COMPUTER_NAME.into(), place: Place::Sealed { root: std::env::temp_dir().join("anybot-no-seal") } };
|
||
assert!(matches!(decide_on("shell", r#"{"command":"cat ~/.ssh/id_rsa"}"#, &t, &[]), Decision::Deny(_)));
|
||
let (r, ok) = run_on("shell", r#"{"command":"echo ran"}"#, &t, &AtomicBool::new(false), &RunIds::default());
|
||
assert!(!ok && !r.contains("ran"), "{r}");
|
||
return;
|
||
}
|
||
let Some(home) = dirs::home_dir() else { return };
|
||
let root = home.join("AnyBot 工作區").join(format!("test-seal-{}", std::process::id()));
|
||
std::fs::create_dir_all(&root).unwrap();
|
||
let t = Target { name: crate::model::BOT_COMPUTER_NAME.into(), place: Place::Sealed { root: root.clone() } };
|
||
let c = AtomicBool::new(false);
|
||
let (r, ok) = run_on("shell", r#"{"command":"echo hi > mine.txt && cat mine.txt"}"#, &t, &c, &RunIds::default());
|
||
assert!(ok && r.contains("hi"), "{r}");
|
||
let (r, _) = run_on("shell", r#"{"command":"ls ~/Documents >/dev/null 2>&1 && echo SEEN || echo HIDDEN"}"#, &t, &c, &RunIds::default());
|
||
assert!(r.contains("HIDDEN"), "{r}");
|
||
assert!(matches!(decide_on("read_file", r#"{"path":"~/Documents/x.txt"}"#, &t, &[]), Decision::Deny(_)));
|
||
assert_eq!(decide_on("edit_file", r#"{"path":"a.txt","new_text":"x"}"#, &t, &[]), Decision::Allow);
|
||
let _ = std::fs::remove_dir_all(&root);
|
||
}
|
||
|
||
#[test]
|
||
fn ssh_commands_are_quoted() {
|
||
assert_eq!(ssh_command("me@box", "/srv/app", "echo 'hi'"), "ssh -o BatchMode=yes -o ConnectTimeout=15 'me@box' 'cd '\\''/srv/app'\\'' && echo '\\''hi'\\'''");
|
||
let t = Target { name: "工作站".into(), place: Place::Ssh { target: "me@box".into(), dir: String::new() } };
|
||
assert!(matches!(decide_on("shell", r#"{"command":"npm test"}"#, &t, &[]), Decision::Ask { ref reason, .. } if reason.contains("工作站")));
|
||
assert_eq!(decide_on("shell", r#"{"command":"ls"}"#, &t, &[]), Decision::Allow);
|
||
let s = specs_for(&["A".into(), "B".into()], "A");
|
||
assert_eq!(s[0]["parameters"]["properties"]["computer"]["enum"][1], "B");
|
||
}
|
||
|
||
#[test]
|
||
fn stop_ends_the_whole_tree() {
|
||
let w = wd();
|
||
let cancel = Arc::new(AtomicBool::new(false));
|
||
let c2 = cancel.clone();
|
||
let marker = format!("anybot-sleep-{}", std::process::id());
|
||
let cmd = format!(r#"{{"command":"sh -c 'exec -a {marker} sleep 30' & sleep 30","timeout_ms":20000}}"#);
|
||
let h = std::thread::spawn(move || run("shell", &cmd, &wd(), &c2, &RunIds::default()));
|
||
std::thread::sleep(Duration::from_millis(800));
|
||
cancel.store(true, Ordering::Relaxed);
|
||
let (r, ok) = h.join().unwrap();
|
||
assert!(!ok && r.contains("已停止"), "{r}");
|
||
std::thread::sleep(Duration::from_millis(300));
|
||
let ps = Command::new("ps").args(["-A", "-o", "command="]).output().unwrap();
|
||
assert!(!String::from_utf8_lossy(&ps.stdout).contains(&marker), "child still running");
|
||
let _ = w;
|
||
}
|
||
}
|