AnyBot/src/tools.rs

1807 lines
88 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;
}
}