use std::io::{Read, Write};
use std::os::unix::process::CommandExt;
use std::path::{Component, Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
pub const ENV_SANDBOX: &str = "CCTOP_SANDBOX";
const ENV_SOCKET: &str = "CCTOP_SANDBOX_SOCKET";
const ENV_MOUNT: &str = "CCTOP_SANDBOX_MOUNT";
const FUSE_SUPER_MAGIC: i64 = 0x6573_5546;
use crate::ssh_master::{SOCKET_PATH_MAX, mux_args};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Reach {
ShellPrefix,
ShellSetting,
Local,
}
pub fn reach(agent: &str) -> Reach {
match agent {
"claude" => Reach::ShellPrefix,
"opencode" => Reach::ShellSetting,
_ => Reach::Local,
}
}
pub fn local_only(agent: &str) -> String {
format!("{agent} can't run commands on a remote host yet")
}
const MOUNT_WAIT: Duration = Duration::from_secs(20);
const FILE_TOOLS: &str = "Read|Edit|Write|MultiEdit|NotebookEdit|Glob|Grep";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Spec {
pub host: String,
pub path: String,
}
impl Spec {
pub fn parse(spec: &str) -> Option<Spec> {
let (host, path) = match spec.strip_prefix('[') {
Some(rest) => {
let (addr, path) = rest.split_once("]:")?;
(format!("[{addr}]"), path)
}
None => {
let (host, path) = spec.split_once(':')?;
(host.to_string(), path)
}
};
(!host.is_empty() && host != "[]" && !path.is_empty()).then(|| Spec {
host,
path: path.to_string(),
})
}
}
pub fn host_of(sandbox: &str) -> &str {
if sandbox.starts_with('[')
&& let Some(end) = sandbox.find("]:")
{
return &sandbox[..=end];
}
sandbox.split_once(':').map_or(sandbox, |(host, _)| host)
}
fn path_of(sandbox: &str) -> Option<&str> {
sandbox
.strip_prefix(host_of(sandbox))?
.strip_prefix(':')
.filter(|p| !p.is_empty())
}
pub fn sh_quote(text: &str) -> String {
let plain = !text.is_empty()
&& text
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"/._-+=:,@%".contains(&b));
match plain {
true => text.to_string(),
false => format!("'{}'", text.replace('\'', r"'\''")),
}
}
const USAGE: &str = "\
usage: cctop sandbox [--agent claude|opencode] <host>:<path> [agent args…]
Run an agent here (Claude Code unless --agent says otherwise) with its shell
commands running on <host>, in <path>, over one ssh connection, and <path>
mounted here with sshfs so the file tools see the host's files: at the same
path when that can be made here, else under ~/.cache/cctop/remote/<host>/.
Nothing is installed on the host.
cctop sandbox devbox:/srv/api
cctop sandbox devbox:~/src/api --model opus
cctop sandbox --agent opencode devbox:~/src/api
Needs sshfs and fusermount3 here, and offers to install sshfs when it is
missing and the terminal can answer; bash and setsid on the host.";
pub const ENV_HOLD: &str = "CCTOP_SANDBOX_HOLD";
pub fn run_held(args: &[String]) -> anyhow::Result<i32> {
let result = run(args);
match result {
Err(e) if std::env::var_os(ENV_HOLD).is_some() => {
eprintln!("\n{e:#}\n\nPress Enter to close this tab.");
let mut line = String::new();
let _ = std::io::stdin().read_line(&mut line);
Ok(1)
}
other => other,
}
}
pub fn run(args: &[String]) -> anyhow::Result<i32> {
let Some(first) = args.first() else {
eprintln!("{USAGE}");
return Ok(2);
};
if matches!(first.as_str(), "-h" | "--help") {
println!("{USAGE}");
return Ok(0);
}
let (agent, args) = split_agent(args)?;
let Some(first) = args.first() else {
anyhow::bail!("expected <host>:<path>\n\n{USAGE}");
};
let Some(spec) = Spec::parse(first) else {
anyhow::bail!("expected <host>:<path>, got `{first}`\n\n{USAGE}");
};
if reach(&agent) == Reach::Local {
anyhow::bail!("{}", local_only(&agent));
}
crate::sshfs::ensure()?;
let fusermount = require_tools()?;
let exe = std::env::current_exe()?;
let mut sandbox = Sandbox {
host: spec.host.clone(),
socket: socket_path(&spec)?,
fusermount,
master: None,
borrowed: false,
sshfs: None,
root: None,
created: Vec::new(),
shim: None,
log: crate::config::runtime_base().join("cctop"),
};
match crate::ssh_master::socket_for(&spec.host)
.filter(|shared| crate::ssh_master::is_up(shared, &spec.host))
{
Some(shared) => {
crate::ssh_master::take_lease(&shared, std::process::id());
sandbox.socket = shared;
sandbox.borrowed = true;
}
None => {
eprintln!("cctop sandbox: connecting to {}…", spec.host);
sandbox.connect()?;
}
}
let probe = sandbox.probe(&spec.path)?;
let root = PathBuf::from(&probe.root);
if let Err(why) = crate::remote_fs::verdict(&probe.facts()) {
anyhow::bail!("{}:{} can't be used: {why}", spec.host, probe.root);
}
let point = crate::remote_fs::mount_point(
&spec.host,
&root,
&crate::remote_fs::mounts_here(),
&crate::remote_fs::here,
&crate::config::CACHE_DIR.join("remote"),
)
.map_err(|why| anyhow::anyhow!("{}:{} can't be mounted: {why}", spec.host, probe.root))?;
sandbox.created = prepare_mountpoint(&point, sandbox.fusermount)?;
match point == root {
true => eprintln!(
"cctop sandbox: mounting {}:{} at the same path here…",
spec.host, probe.root
),
false => eprintln!(
"cctop sandbox: mounting {}:{} at {}…",
spec.host,
probe.root,
point.display()
),
}
sandbox.mount(&probe.root, &point)?;
let map = PathMap::new(&point, &root);
let target = format!("{}:{}", spec.host, probe.root);
let mut env = vec![
(ENV_SANDBOX.to_string(), target.clone()),
(
ENV_SOCKET.to_string(),
sandbox.socket.to_string_lossy().into_owned(),
),
];
if map.is_some() {
env.push((ENV_MOUNT.to_string(), point.to_string_lossy().into_owned()));
}
let mut argv = vec![agent.clone()];
match reach(&agent) {
Reach::ShellPrefix => {
env.extend([
("SHELL".to_string(), "/bin/bash".to_string()),
("CLAUDE_CODE_SHELL".to_string(), "/bin/bash".to_string()),
(
"CLAUDE_CODE_SHELL_PREFIX".to_string(),
format!("{} --sandbox-exec", exe.display()),
),
]);
argv.extend([
"--settings".to_string(),
guard_settings(&exe, &point, map.as_ref()),
"--append-system-prompt".to_string(),
system_prompt(&spec.host, &probe, map.as_ref()),
]);
}
Reach::ShellSetting => {
let shim = write_shell_shim(&exe)?;
let content = opencode_config(
std::env::var("OPENCODE_CONFIG_CONTENT").ok().as_deref(),
&shim,
);
sandbox.shim = shim.parent().map(Path::to_path_buf);
env.extend([
("SHELL".to_string(), shim.to_string_lossy().into_owned()),
("OPENCODE_CONFIG_CONTENT".to_string(), content),
]);
argv.push("--standalone".to_string());
}
Reach::Local => unreachable!("checked before connecting"),
}
argv.extend(args[1..].iter().cloned());
let stop = Stop::install();
let code = crate::shim::run_in(&argv, Some(&point), &env, |pid| stop.watch(pid))?;
drop(sandbox);
Ok(code)
}
fn require_tools() -> anyhow::Result<&'static str> {
let on_path = |name: &str| {
std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default())
.any(|dir| dir.join(name).is_file())
};
let fusermount = ["fusermount3", "fusermount"]
.into_iter()
.find(|name| on_path(name));
match (on_path("sshfs"), fusermount) {
(true, Some(fusermount)) => Ok(fusermount),
(false, _) => anyhow::bail!("cctop sandbox needs sshfs on this machine"),
(true, None) => anyhow::bail!(
"cctop sandbox needs fusermount3 on this machine.\n \
Debian/Ubuntu: sudo apt-get install fuse3"
),
}
}
fn split_agent(args: &[String]) -> anyhow::Result<(String, &[String])> {
match args.first().map(String::as_str) {
Some("--agent") => match args.get(1) {
Some(agent) => Ok((agent.clone(), &args[2..])),
None => anyhow::bail!("--agent needs a name\n\n{USAGE}"),
},
Some(flag) if flag.starts_with("--agent=") => {
Ok((flag["--agent=".len()..].to_string(), &args[1..]))
}
_ => Ok(("claude".to_string(), args)),
}
}
fn write_shell_shim(exe: &Path) -> anyhow::Result<PathBuf> {
use std::os::unix::fs::PermissionsExt;
let dir = crate::config::runtime_base()
.join("cctop")
.join(format!("sbx-shell-{}", std::process::id()));
if !crate::ssh_master::private_dir(&dir) {
anyhow::bail!("could not create {}", dir.display());
}
let path = dir.join("bash");
std::fs::write(
&path,
format!(
"#!/bin/sh\nexec {} --sandbox-shell \"$@\"\n",
sh_quote(&exe.to_string_lossy())
),
)?;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700))?;
Ok(path)
}
fn opencode_config(existing: Option<&str>, shell: &Path) -> String {
let mut config = existing
.and_then(|text| serde_json::from_str::<serde_json::Value>(text).ok())
.filter(serde_json::Value::is_object)
.unwrap_or_else(|| serde_json::json!({}));
config["shell"] = serde_json::Value::String(shell.to_string_lossy().into_owned());
if !config["permission"].is_object() {
config["permission"] = serde_json::json!({});
}
config["permission"]["external_directory"] = serde_json::Value::String("deny".into());
config.to_string()
}
fn socket_path(spec: &Spec) -> anyhow::Result<PathBuf> {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
(&spec.host, &spec.path, std::process::id()).hash(&mut hasher);
let name = format!("sbx-{:08x}.sock", hasher.finish() as u32);
for dir in crate::ssh_master::socket_dirs() {
let path = dir.join(&name);
if path.as_os_str().len() <= SOCKET_PATH_MAX && crate::ssh_master::private_dir(&dir) {
return Ok(path);
}
}
anyhow::bail!("no directory short enough for an ssh control socket (the limit is 108 bytes)")
}
struct Sandbox {
host: String,
socket: PathBuf,
fusermount: &'static str,
master: Option<Child>,
borrowed: bool,
sshfs: Option<Child>,
root: Option<PathBuf>,
created: Vec<PathBuf>,
shim: Option<PathBuf>,
log: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
struct Probe {
hostname: String,
uname: String,
root: String,
readable: bool,
writable: bool,
}
impl Probe {
fn facts(&self) -> crate::remote_fs::RemoteFacts {
let there = !self.root.is_empty();
crate::remote_fs::RemoteFacts {
exists: there,
is_dir: there,
readable: self.readable,
writable: self.writable,
resolved: there.then(|| self.root.clone()),
}
}
}
impl Sandbox {
fn log_file(&self, name: &str) -> (PathBuf, Stdio) {
let path = self
.log
.join(format!("sandbox-{}-{name}.log", std::process::id()));
let _ = std::fs::create_dir_all(&self.log);
let stdio = std::fs::File::create(&path)
.map(Stdio::from)
.unwrap_or_else(|_| Stdio::null());
(path, stdio)
}
fn connect(&mut self) -> anyhow::Result<()> {
let (log, stderr) = self.log_file("ssh");
let mut cmd = Command::new("ssh");
cmd.args(["-N", "-M", "-S"])
.arg(&self.socket)
.args([
"-o",
"ControlPersist=no",
"-o",
"ClearAllForwardings=yes",
"-o",
"ServerAliveInterval=15",
"-o",
"ServerAliveCountMax=3",
"-o",
"ConnectTimeout=15",
])
.arg(&self.host)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(stderr);
die_with_parent(&mut cmd);
self.master = Some(cmd.spawn()?);
loop {
if let Some(Ok(Some(status))) = self.master.as_mut().map(Child::try_wait) {
self.master = None;
anyhow::bail!(
"could not connect to {} (ssh {status}){}",
self.host,
tail_of(&log)
);
}
let up = Command::new("ssh")
.arg("-S")
.arg(&self.socket)
.args(["-O", "check"])
.arg(&self.host)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|s| s.success());
if up {
return Ok(());
}
std::thread::sleep(Duration::from_millis(100));
}
}
fn probe(&self, path: &str) -> anyhow::Result<Probe> {
let out = Command::new("ssh")
.args(mux_args(&self.socket))
.arg(&self.host)
.arg("--")
.arg(probe_command(path))
.stdin(Stdio::null())
.output()?;
let lines = String::from_utf8_lossy(&out.stdout).into_owned();
let said = parse_probe(&lines);
let has = |key: &str| {
lines
.lines()
.any(|l| l.strip_prefix(key).is_some_and(|v| v.starts_with("=/")))
};
if !out.status.success() && said.uname.is_empty() {
anyhow::bail!(
"{} did not answer the probe: {}",
self.host,
String::from_utf8_lossy(&out.stderr).trim()
);
}
if !has("bash") {
anyhow::bail!(
"{} has no bash on its PATH, which every Bash call runs in",
self.host
);
}
if !has("setsid") {
anyhow::bail!(
"{} has no setsid on its PATH, which is how an interrupted command is stopped there",
self.host
);
}
if said.root.is_empty() {
anyhow::bail!("{path} is not a directory on {}", self.host);
}
Ok(said)
}
fn mount(&mut self, remote: &str, root: &Path) -> anyhow::Result<()> {
let (log, stderr) = self.log_file("sshfs");
let ssh_command = format!(
"ssh -S {} -o ControlMaster=no -o BatchMode=yes",
self.socket.display()
);
let mut cmd = Command::new("sshfs");
cmd.arg("-f")
.arg("-o")
.arg(format!("ssh_command={ssh_command}"))
.args([
"-o",
"idmap=user,reconnect,auto_cache,attr_timeout=1,entry_timeout=1,\
negative_timeout=0,dcache_timeout=1",
])
.arg(format!("{}:{remote}", self.host))
.arg(root)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(stderr);
die_with_parent(&mut cmd);
self.sshfs = Some(cmd.spawn()?);
let started = Instant::now();
loop {
if let Some(Ok(Some(status))) = self.sshfs.as_mut().map(Child::try_wait) {
self.sshfs = None;
anyhow::bail!(
"sshfs could not mount {} ({status}){}",
root.display(),
tail_of(&log)
);
}
if is_fuse_mount(root) {
self.root = Some(root.to_path_buf());
return Ok(());
}
if started.elapsed() > MOUNT_WAIT {
anyhow::bail!(
"sshfs had not mounted {} after {}s{}",
root.display(),
MOUNT_WAIT.as_secs(),
tail_of(&log)
);
}
std::thread::sleep(Duration::from_millis(50));
}
}
}
impl Drop for Sandbox {
fn drop(&mut self) {
if let Some(root) = self.root.take() {
let quiet = |args: &[&str]| {
Command::new(self.fusermount)
.args(args)
.arg(&root)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|s| s.success())
};
if !quiet(&["-u"]) && !quiet(&["-uz"]) {
eprintln!(
"cctop sandbox: could not unmount {}; `{} -uz {}` will",
root.display(),
self.fusermount,
sh_quote(&root.to_string_lossy())
);
}
}
if let Some(sshfs) = self.sshfs.take() {
reap(sshfs);
}
for dir in &self.created {
let _ = std::fs::remove_dir(dir);
}
if let Some(dir) = self.shim.take() {
let _ = std::fs::remove_dir_all(dir);
}
if self.borrowed {
crate::ssh_master::release(&self.host, &self.socket, std::process::id());
return;
}
if let Some(master) = self.master.take() {
let _ = Command::new("ssh")
.arg("-S")
.arg(&self.socket)
.args(["-O", "exit"])
.arg(&self.host)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
reap(master);
}
let _ = std::fs::remove_file(&self.socket);
}
}
fn reap(mut child: Child) {
let started = Instant::now();
while started.elapsed() < Duration::from_secs(3) {
if let Ok(Some(_)) = child.try_wait() {
return;
}
std::thread::sleep(Duration::from_millis(50));
}
let _ = child.kill();
let _ = child.wait();
}
fn die_with_parent(cmd: &mut Command) {
unsafe {
cmd.pre_exec(|| {
if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
}
fn tail_of(log: &Path) -> String {
let text = std::fs::read_to_string(log).unwrap_or_default();
let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
match lines.len() {
0 => String::new(),
n => format!(":\n {}", lines[n.saturating_sub(5)..].join("\n ")),
}
}
fn probe_command(path: &str) -> String {
const SCRIPT: &str = r#"p=$1
case $p in "~") p=$HOME ;; "~/"*) p=$HOME/${p#"~/"} ;; esac
echo "hostname=$(hostname 2>/dev/null || uname -n)"
echo "uname=$(uname -sr)"
echo "bash=$(command -v bash)"
echo "setsid=$(command -v setsid)"
if cd -- "$p" 2>/dev/null; then
echo "root=$(pwd -P)"
[ -r . ] && echo readable=1
[ -w . ] && echo writable=1
fi"#;
format!(
"exec sh -c {} cctop-sandbox {}",
sh_quote(SCRIPT),
sh_quote(path)
)
}
fn parse_probe(out: &str) -> Probe {
let mut probe = Probe::default();
for line in out.lines() {
match line.split_once('=') {
Some(("hostname", v)) => probe.hostname = v.trim().to_string(),
Some(("uname", v)) => probe.uname = v.trim().to_string(),
Some(("root", v)) if v.starts_with('/') => probe.root = v.to_string(),
Some(("readable", "1")) => probe.readable = true,
Some(("writable", "1")) => probe.writable = true,
_ => {}
}
}
probe
}
fn prepare_mountpoint(root: &Path, fusermount: &str) -> anyhow::Result<Vec<PathBuf>> {
if let Some(fstype) = mount_type_at(root) {
let stale = fstype.starts_with("fuse")
&& std::fs::metadata(root).err().and_then(|e| e.raw_os_error()) == Some(libc::ENOTCONN);
if !stale {
anyhow::bail!(
"{} is already a mount point ({fstype}) — is another `cctop sandbox` using it?",
root.display()
);
}
eprintln!(
"cctop sandbox: clearing a stale mount at {}",
root.display()
);
let _ = Command::new(fusermount)
.arg("-uz")
.arg(root)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
match std::fs::metadata(root) {
Ok(meta) if !meta.is_dir() => {
anyhow::bail!("{} exists here and is not a directory", root.display())
}
Ok(_) => {
if std::fs::read_dir(root)?.next().is_some() {
anyhow::bail!(
"{} exists here and is not empty, and the mount would hide what is there",
root.display()
);
}
Ok(Vec::new())
}
Err(_) => {
let mut missing: Vec<PathBuf> = root
.ancestors()
.take_while(|dir| !dir.exists())
.map(Path::to_path_buf)
.collect();
if let Err(e) = std::fs::create_dir_all(root) {
anyhow::bail!(
"could not create {} to mount the host's directory on ({e})",
root.display()
);
}
missing.sort_by_key(|dir| std::cmp::Reverse(dir.components().count()));
Ok(missing)
}
}
}
fn mount_type_at(path: &Path) -> Option<String> {
let table = std::fs::read_to_string("/proc/self/mountinfo").ok()?;
mount_type_in(&table, path)
}
fn mount_type_in(mountinfo: &str, path: &Path) -> Option<String> {
crate::remote_fs::parse_mountinfo(mountinfo)
.into_iter()
.rev()
.find(|m| m.point == path)
.map(|m| m.fstype)
}
fn is_fuse_mount(path: &Path) -> bool {
if !mount_type_at(path).is_some_and(|t| t.starts_with("fuse")) {
return false;
}
let Ok(c_path) = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()) else {
return false;
};
let mut buf: libc::statfs = unsafe { std::mem::zeroed() };
let rc = unsafe { libc::statfs(c_path.as_ptr(), &raw mut buf) };
rc == 0 && buf.f_type as i64 == FUSE_SUPER_MAGIC
}
fn guard_settings(exe: &Path, mount: &Path, map: Option<&PathMap>) -> String {
let mut command = format!(
"{} --sandbox-guard {}",
sh_quote(&exe.to_string_lossy()),
sh_quote(&mount.to_string_lossy())
);
if let Some(map) = map {
command.push_str(" --remote ");
command.push_str(&sh_quote(&map.remote));
}
serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": FILE_TOOLS,
"hooks": [{ "type": "command", "command": command }],
}],
},
})
.to_string()
}
fn system_prompt(host: &str, probe: &Probe, map: Option<&PathMap>) -> String {
let mount = match map {
None => format!(
"see the host's {root} through an sshfs mount at the same path",
root = probe.root
),
Some(map) => format!(
"see the host's {root} through an sshfs mount at {local}, which is your working \
directory. The two names are one directory: in a Bash command, {local} is \
rewritten to {root} before it is sent, and a file tool given a path under {root} \
is pointed at {local} — so either name works, though output from the host will \
say {root}",
root = map.remote,
local = map.local,
),
};
format!(
"You are working on the remote host `{host}` (hostname `{hostname}`, {uname}) through \
cctop sandbox. Your Bash tool runs every command on that host, over ssh, in the \
matching working directory — programs, services, processes, package managers and \
environment are the host's. The file tools (Read, Edit, Write, Glob, Grep, \
NotebookEdit) run on the local machine and {mount}; they are refused anywhere outside \
it. To read or change any other path on the host, use Bash.",
hostname = probe.hostname,
uname = probe.uname,
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PathMap {
pub local: String,
pub remote: String,
}
impl PathMap {
pub fn new(local: &Path, remote: &Path) -> Option<PathMap> {
(local != remote).then(|| PathMap {
local: local.to_string_lossy().into_owned(),
remote: remote.to_string_lossy().into_owned(),
})
}
fn from_env() -> Option<PathMap> {
let local = std::env::var(ENV_MOUNT)
.ok()
.filter(|l| l.starts_with('/'))?;
let target = std::env::var(ENV_SANDBOX).ok()?;
let remote = path_of(&target).filter(|r| r.starts_with('/'))?;
PathMap::new(Path::new(&local), Path::new(remote))
}
pub fn to_remote(&self, path: &Path) -> PathBuf {
rebase(path, Path::new(&self.local), Path::new(&self.remote))
}
pub fn to_local(&self, path: &Path) -> PathBuf {
rebase(path, Path::new(&self.remote), Path::new(&self.local))
}
pub fn command_to_remote<'a>(&self, text: &'a str) -> std::borrow::Cow<'a, str> {
swap(text, &self.local, &self.remote)
}
}
fn rebase(path: &Path, from: &Path, to: &Path) -> PathBuf {
match path.strip_prefix(from) {
Ok(rest) if rest.as_os_str().is_empty() => to.to_path_buf(),
Ok(rest) => to.join(rest),
Err(_) => path.to_path_buf(),
}
}
fn names_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b"._-+~@%".contains(&b) || b >= 0x80
}
pub fn swap<'a>(text: &'a str, from: &str, to: &str) -> std::borrow::Cow<'a, str> {
if from.is_empty() || from == "/" || !text.contains(from) {
return std::borrow::Cow::Borrowed(text);
}
let bytes = text.as_bytes();
let mut out = String::with_capacity(text.len());
let mut copied = 0;
let mut at = 0;
while let Some(found) = text[at..].find(from) {
let start = at + found;
let end = start + from.len();
let before_ok = start == 0 || {
let b = bytes[start - 1];
b != b'/' && !names_byte(b)
};
let after_ok = bytes.get(end).is_none_or(|&b| b == b'/' || !names_byte(b));
if before_ok && after_ok {
out.push_str(&text[copied..start]);
match (to, bytes.get(end)) {
("/", Some(b'/')) => {}
_ => out.push_str(to),
}
copied = end;
at = end;
} else {
at = start + 1;
}
}
if copied == 0 {
return std::borrow::Cow::Borrowed(text);
}
out.push_str(&text[copied..]);
std::borrow::Cow::Owned(out)
}
struct Stop {
pid: std::sync::Arc<std::sync::atomic::AtomicU32>,
}
static STOP_PIPE: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1);
extern "C" fn on_stop_signal(_: libc::c_int) {
let fd = STOP_PIPE.load(std::sync::atomic::Ordering::Relaxed);
if fd >= 0 {
unsafe { libc::write(fd, [1u8].as_ptr().cast(), 1) };
}
}
impl Stop {
fn install() -> Stop {
let pid = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
let mut fds = [-1; 2];
if unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) } == 0 {
STOP_PIPE.store(fds[1], std::sync::atomic::Ordering::Relaxed);
for signal in [libc::SIGTERM, libc::SIGHUP] {
unsafe {
libc::signal(
signal,
on_stop_signal as extern "C" fn(libc::c_int) as libc::sighandler_t,
)
};
}
let read_fd = fds[0];
let pid = std::sync::Arc::clone(&pid);
std::thread::spawn(move || {
let mut byte = [0u8; 1];
if unsafe { libc::read(read_fd, byte.as_mut_ptr().cast(), 1) } == 1 {
let agent = pid.load(std::sync::atomic::Ordering::Relaxed);
if agent > 0 {
unsafe { libc::kill(agent as libc::pid_t, libc::SIGHUP) };
std::thread::sleep(Duration::from_secs(5));
unsafe { libc::kill(agent as libc::pid_t, libc::SIGKILL) };
}
}
});
}
Stop { pid }
}
fn watch(&self, pid: u32) {
self.pid.store(pid, std::sync::atomic::Ordering::Relaxed);
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum Call<'a> {
Remote { head: &'a str, cwd_file: String },
Local,
Unreadable,
}
pub fn classify(command: &str) -> Call<'_> {
const PWD: &str = "pwd -P >|";
let trimmed = command.trim_end();
let Some(at) = trimmed.rfind(PWD) else {
return Call::Local;
};
let file = trimmed[at + PWD.len()..].trim();
let file = match file.strip_prefix('\'').and_then(|f| f.strip_suffix('\'')) {
Some(quoted) if !quoted.contains('\'') => quoted,
Some(_) => return Call::Unreadable,
None if !file.is_empty() && !file.contains(char::is_whitespace) => file,
None if file.contains(char::is_whitespace) => return Call::Local,
None => return Call::Unreadable,
};
if !file.starts_with('/') {
return Call::Unreadable;
}
match trimmed[..at].trim_end().strip_suffix("&&") {
Some(head) => Call::Remote {
head: head.trim_end(),
cwd_file: file.to_string(),
},
None => Call::Unreadable,
}
}
pub fn exec(args: &[String]) -> i32 {
let command = args.first().map(String::as_str).unwrap_or_default();
let target = std::env::var(ENV_SANDBOX).ok().filter(|t| !t.is_empty());
let socket = std::env::var_os(ENV_SOCKET).filter(|s| !s.is_empty());
match (classify(command), target, socket) {
(Call::Remote { head, cwd_file }, Some(target), Some(socket)) => {
let map = PathMap::from_env();
match remote(
head,
Some(&cwd_file),
&target,
Path::new(&socket),
map.as_ref(),
) {
Ok(code) => code,
Err(e) => {
eprintln!(
"cctop sandbox: could not run this on {}: {e}",
host_of(&target)
);
255
}
}
}
(Call::Unreadable, target, _) => {
eprintln!(
"cctop sandbox: this Bash call was not in a shape cctop recognises, so it was \
not run — running it here would act on this machine rather than on {}.",
target.as_deref().map(host_of).unwrap_or("the host")
);
1
}
_ => {
let e = Command::new("/bin/bash").arg("-c").arg(command).exec();
eprintln!("cctop sandbox: could not run bash: {e}");
127
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum ShellCall<'a> {
Command(&'a str),
Interactive,
Unknown,
}
pub fn shell_call(args: &[String]) -> ShellCall<'_> {
let mut i = 0;
while let Some(arg) = args.get(i) {
match arg.as_str() {
"--login" | "--norc" | "--noprofile" | "-l" | "-i" => {}
"--" => break,
flag if flag.starts_with('-') && !flag.starts_with("--") => {
if flag.contains('c') {
return match args.get(i + 1) {
Some(command) => ShellCall::Command(command),
None => ShellCall::Unknown,
};
}
if !flag[1..].chars().all(|c| "lis".contains(c)) {
return ShellCall::Unknown;
}
}
_ => return ShellCall::Unknown,
}
i += 1;
}
match i + 1 >= args.len() {
true => ShellCall::Interactive,
false => ShellCall::Unknown,
}
}
pub fn shell(args: &[String]) -> i32 {
let target = std::env::var(ENV_SANDBOX).ok().filter(|t| !t.is_empty());
let socket = std::env::var_os(ENV_SOCKET).filter(|s| !s.is_empty());
let (Some(target), Some(socket)) = (target, socket) else {
eprintln!("cctop sandbox: this shell only runs inside `cctop sandbox`");
return 126;
};
let host = host_of(&target);
let map = PathMap::from_env();
match shell_call(args) {
ShellCall::Command(command) => {
match remote(command, None, &target, Path::new(&socket), map.as_ref()) {
Ok(code) => code,
Err(e) => {
eprintln!("cctop sandbox: could not run this on {host}: {e}");
255
}
}
}
ShellCall::Interactive => {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
let cwd = match &map {
Some(map) => map.to_remote(&cwd),
None => cwd,
};
let line = format!(
"cd -- {} 2>/dev/null; exec \"${{SHELL:-sh}}\" -l",
sh_quote(&cwd.to_string_lossy())
);
let mut cmd = Command::new("ssh");
cmd.args(mux_args(Path::new(&socket)))
.arg("-t")
.arg(host)
.arg("--")
.arg(line);
let e = cmd.exec();
eprintln!("cctop sandbox: could not run ssh: {e}");
255
}
ShellCall::Unknown => {
eprintln!(
"cctop sandbox: `{}` is not a shell call cctop can send to {host}, so it was not \
run — running it here would act on this machine instead.",
args.join(" ")
);
126
}
}
}
fn remote(
head: &str,
cwd_file: Option<&str>,
target: &str,
socket: &Path,
map: Option<&PathMap>,
) -> std::io::Result<i32> {
let host = host_of(target);
let (head, cwd) = outbound(head, &std::env::current_dir()?, map);
let nonce = nonce();
let mut cmd = Command::new("ssh");
cmd.args(mux_args(socket))
.arg(host)
.arg("--")
.arg(remote_line(&head, &nonce, &cwd.to_string_lossy()));
let mut stderr = std::io::stderr();
let (code, cwd_now) = run_remote(cmd, &nonce, &mut stderr)?;
if let (Some(dir), Some(file)) = (cwd_now, cwd_file) {
let _ = std::fs::write(file, format!("{}\n", inbound(dir, map)));
}
if code == 255 {
let _ = writeln!(
stderr,
"cctop sandbox: ssh to {host} exited 255 — the connection failed, or the command \
itself exited 255."
);
}
Ok(code)
}
fn outbound<'a>(
head: &'a str,
cwd: &Path,
map: Option<&PathMap>,
) -> (std::borrow::Cow<'a, str>, PathBuf) {
match map {
Some(map) => (map.command_to_remote(head), map.to_remote(cwd)),
None => (std::borrow::Cow::Borrowed(head), cwd.to_path_buf()),
}
}
fn inbound(dir: String, map: Option<&PathMap>) -> String {
match map {
Some(map) => map.to_local(Path::new(&dir)).to_string_lossy().into_owned(),
None => dir,
}
}
fn nonce() -> String {
let mut bytes = [0u8; 8];
let read = std::fs::File::open("/dev/urandom").and_then(|mut f| f.read_exact(&mut bytes));
if read.is_err() {
let seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or_default()
^ u128::from(std::process::id());
bytes.copy_from_slice(&seed.to_le_bytes()[..8]);
}
let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
format!("@@cctop-cwd-{hex}@@")
}
const WATCHDOG: &str = r#"cd -- "$2" 2>/dev/null || { printf 'cctop sandbox: %s is not a directory on %s\n' "$2" "$(hostname)" >&2; exit 1; }
exec 3<&0 0</dev/null
setsid bash -lc "$1" 3<&- &
pid=$!
{ while read -r -u 3 _; do :; done; kill -TERM -- "-$pid"; sleep 2 3<&-; kill -KILL -- "-$pid"; } >/dev/null 2>&1 &
watchdog=$!
exec 3<&-
wait "$pid"; rc=$?
kill "$watchdog" 2>/dev/null; wait "$watchdog" 2>/dev/null
kill -TERM -- "-$pid" 2>/dev/null
exit "$rc""#;
fn remote_line(head: &str, nonce: &str, cwd: &str) -> String {
let command = format!("{head} && printf '\\n{nonce}%s\\n' \"$(pwd -P)\" >&2");
format!(
"exec bash -c {} cctop-sandbox {} {}",
sh_quote(WATCHDOG),
sh_quote(&command),
sh_quote(cwd)
)
}
fn run_remote(
mut cmd: Command,
nonce: &str,
err: &mut dyn Write,
) -> std::io::Result<(i32, Option<String>)> {
let mut child = cmd.stdin(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
let hold = child.stdin.take();
let mut strip = Strip::new(nonce);
if let Some(mut from) = child.stderr.take() {
let mut buf = [0u8; 8192];
loop {
match from.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let _ = err.write_all(&strip.feed(&buf[..n]));
let _ = err.flush();
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(_) => break,
}
}
}
let _ = err.write_all(&strip.finish());
let status = child.wait()?;
drop(hold);
use std::os::unix::process::ExitStatusExt;
let code = status
.code()
.or_else(|| status.signal().map(|s| 128 + s))
.unwrap_or(1);
Ok((code, strip.cwd))
}
pub struct Strip {
tag: Vec<u8>,
held: Vec<u8>,
state: StripState,
pub cwd: Option<String>,
}
#[derive(Debug, PartialEq, Eq)]
enum StripState {
Looking,
Reading,
Done,
}
impl Strip {
pub fn new(nonce: &str) -> Strip {
let mut tag = b"\n".to_vec();
tag.extend_from_slice(nonce.as_bytes());
Strip {
tag,
held: Vec::new(),
state: StripState::Looking,
cwd: None,
}
}
pub fn feed(&mut self, chunk: &[u8]) -> Vec<u8> {
self.held.extend_from_slice(chunk);
let mut out = Vec::new();
loop {
match self.state {
StripState::Done => {
out.append(&mut self.held);
return out;
}
StripState::Reading => {
let Some(end) = self.held.iter().position(|&b| b == b'\n') else {
return out;
};
self.cwd = Some(String::from_utf8_lossy(&self.held[..end]).into_owned());
self.held.drain(..=end);
self.state = StripState::Done;
}
StripState::Looking => {
if let Some(at) = find(&self.held, &self.tag) {
out.extend_from_slice(&self.held[..at]);
self.held.drain(..at + self.tag.len());
self.state = StripState::Reading;
continue;
}
let keep = partial_suffix(&self.held, &self.tag);
let pass = self.held.len() - keep;
out.extend(self.held.drain(..pass));
return out;
}
}
}
}
pub fn finish(&mut self) -> Vec<u8> {
match self.state {
StripState::Reading => {
self.cwd = Some(String::from_utf8_lossy(&self.held).into_owned());
self.held.clear();
Vec::new()
}
_ => std::mem::take(&mut self.held),
}
}
}
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}
fn partial_suffix(buf: &[u8], tag: &[u8]) -> usize {
(1..tag.len().min(buf.len() + 1))
.rev()
.find(|&n| buf.ends_with(&tag[..n]))
.unwrap_or(0)
}
pub fn guard(args: &[String]) -> i32 {
std::panic::set_hook(Box::new(|_| std::process::exit(0)));
let mut input = Vec::new();
if std::io::stdin()
.take(1024 * 1024)
.read_to_end(&mut input)
.is_err()
{
return 0;
}
let Ok(event) = serde_json::from_slice::<serde_json::Value>(&input) else {
return 0;
};
let host = std::env::var(ENV_SANDBOX)
.ok()
.map(|t| host_of(&t).to_string())
.unwrap_or_else(|| "the host".to_string());
let (roots, remote) = match args.iter().position(|a| a == "--remote") {
Some(at) => (&args[..at], args.get(at + 1)),
None => (args, None),
};
let map = match (roots.first(), remote) {
(Some(local), Some(remote)) => PathMap::new(Path::new(local), Path::new(remote)),
_ => None,
};
let roots: Vec<PathBuf> = roots.iter().map(PathBuf::from).collect();
if let Some(answer) = answer(&event, &roots, &own_state_dirs(), &host, map.as_ref()) {
println!("{answer}");
}
0
}
fn answer(
event: &serde_json::Value,
roots: &[PathBuf],
own: &[PathBuf],
host: &str,
map: Option<&PathMap>,
) -> Option<serde_json::Value> {
let updated = map.and_then(|map| rewrite(event, map, own));
let reason = match &updated {
Some(input) => {
let mut event = event.clone();
event["tool_input"] = input.clone();
refusal(&event, roots, own, host)
}
None => refusal(event, roots, own, host),
};
match (reason, updated) {
(Some(reason), _) => Some(serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
},
})),
(None, Some(input)) => Some(serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"updatedInput": input,
},
})),
(None, None) => None,
}
}
fn rewrite(event: &serde_json::Value, map: &PathMap, own: &[PathBuf]) -> Option<serde_json::Value> {
let tool = event.get("tool_name")?.as_str()?;
let input = event.get("tool_input")?.as_object()?;
let keys: &[&str] = match tool {
"Read" | "Edit" | "Write" | "MultiEdit" => &["file_path"],
"NotebookEdit" => &["notebook_path"],
"Glob" => &["path", "pattern"],
"Grep" => &["path"],
_ => return None,
};
let local = Path::new(&map.local);
let remote = Path::new(&map.remote);
let mut out = input.clone();
let mut changed = false;
for key in keys {
let Some(path) = input.get(*key).and_then(|v| v.as_str()) else {
continue;
};
if !path.starts_with('/') {
continue;
}
let seen = normalise(Path::new(path));
if seen.starts_with(local)
|| own.iter().any(|dir| is_claude_state(&seen, dir))
|| !seen.starts_with(remote)
{
continue;
}
let moved = match map.remote.as_str() {
"/" => format!("{}{path}", map.local),
prefix => match path.strip_prefix(prefix) {
Some(rest) if rest.is_empty() || rest.starts_with('/') => {
format!("{}{rest}", map.local)
}
_ => map.to_local(&seen).to_string_lossy().into_owned(),
},
};
out.insert(key.to_string(), serde_json::Value::String(moved));
changed = true;
}
changed.then_some(serde_json::Value::Object(out))
}
fn own_state_dirs() -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = dirs::home_dir()
.map(|h| h.join(".claude"))
.into_iter()
.collect();
dirs.push(std::env::temp_dir());
dirs
}
fn refusal(
event: &serde_json::Value,
roots: &[PathBuf],
own: &[PathBuf],
host: &str,
) -> Option<String> {
let tool = event.get("tool_name")?.as_str()?;
let input = event.get("tool_input")?;
let field = |key: &str| {
input
.get(key)
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
};
let path = match tool {
"Read" | "Edit" | "Write" | "MultiEdit" => field("file_path")?.to_string(),
"NotebookEdit" => field("notebook_path")?.to_string(),
"Glob" | "Grep" => match (field("path"), field("pattern")) {
(Some(path), _) => path.to_string(),
(None, Some(pattern)) if tool == "Glob" && pattern.starts_with('/') => {
let fixed = pattern.split(['*', '?', '[', '{']).next().unwrap_or("/");
match fixed.rfind('/') {
Some(0) | None => "/".to_string(),
Some(end) => fixed[..end].to_string(),
}
}
_ => return None,
},
_ => return None,
};
let cwd = event.get("cwd").and_then(|v| v.as_str()).unwrap_or("/");
let full = normalise(&Path::new(cwd).join(&path));
if roots.iter().any(|root| full.starts_with(root))
|| own.iter().any(|dir| is_claude_state(&full, dir))
{
return None;
}
let roots: Vec<String> = roots.iter().map(|r| r.display().to_string()).collect();
Some(format!(
"{} is not on {host}'s mount: the file tools run on the local machine, where only {} is \
{host}'s. To reach {} on {host}, use Bash — it runs there.",
full.display(),
roots.join(", "),
full.display(),
))
}
fn is_claude_state(path: &Path, dir: &Path) -> bool {
let Ok(rest) = path.strip_prefix(dir) else {
return false;
};
if dir.ends_with(".claude") {
return true;
}
rest.components()
.next()
.is_some_and(|c| c.as_os_str().to_string_lossy().starts_with("claude"))
}
fn normalise(path: &Path) -> PathBuf {
let mut out = PathBuf::from("/");
for part in path.components() {
match part {
Component::ParentDir => {
out.pop();
}
Component::Normal(name) => out.push(name),
Component::RootDir | Component::CurDir | Component::Prefix(_) => {}
}
}
out
}
#[cfg(test)]
pub(crate) fn fake_ssh(dir: &Path) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let path = dir.join("ssh");
std::fs::write(
&path,
"#!/bin/sh\n\
while [ $# -gt 0 ]; do\n\
case $1 in\n\
-S|-o|-O) shift 2 ;;\n\
--) shift; break ;;\n\
*) shift ;;\n\
esac\n\
done\n\
exec sh -c \"$*\"\n",
)
.expect("write fake ssh");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).expect("chmod");
path
}
#[cfg(test)]
mod tests {
use super::*;
const BASH_CALL: &str = "source /home/flo/.claude/shell-snapshots/snapshot-bash-1.sh 2>/dev/null || true && { shopt -u extglob || setopt NO_EXTENDED_GLOB NO_BARE_GLOB_QUAL; } >/dev/null 2>&1 || true && { \\builtin unalias -- 'unsetenv'; \\builtin unset -f -- 'unsetenv'; } >/dev/null 2>&1 || true && eval 'ls -la' < /dev/null && pwd -P >| /tmp/claude-ab12-cwd";
#[test]
fn a_spec_splits_at_the_first_colon() {
assert_eq!(
Spec::parse("procdb:/home/f/x"),
Some(Spec {
host: "procdb".into(),
path: "/home/f/x".into()
})
);
assert_eq!(
Spec::parse("flo@box:~/a:b").map(|s| s.path),
Some("~/a:b".into())
);
assert_eq!(
Spec::parse("[::1]:/srv").map(|s| s.host),
Some("[::1]".into())
);
for bad in ["box", ":/srv", "box:", "[::1]/srv"] {
assert_eq!(Spec::parse(bad), None, "{bad}");
}
assert_eq!(host_of("procdb:/srv/x"), "procdb");
assert_eq!(host_of("[::1]:/srv"), "[::1]");
}
#[test]
fn a_sandboxed_row_names_its_host_and_no_stale_branch() {
use crate::ui::columns::{self, ColumnId};
let mut s = crate::session::Session::new(crate::pricing::Provider::Claude, "s".into());
s.sandbox = Some("procdb:/home/f/x".into());
s.label_source = env!("CARGO_MANIFEST_DIR").into();
let cell = columns::render_cell(ColumnId::Host, &s, &chrono::Utc::now());
assert_eq!(cell, "procdb⇄");
assert_eq!(columns::branch_of(&s), None);
}
#[test]
fn the_remote_capability_table() {
assert_eq!(reach("claude"), Reach::ShellPrefix);
assert_eq!(reach("opencode"), Reach::ShellSetting);
for local in ["codex", "devin", "pi", "gemini", "htop"] {
assert_eq!(reach(local), Reach::Local, "{local}");
}
assert_eq!(
local_only("codex"),
"codex can't run commands on a remote host yet"
);
assert!(
crate::alias::AGENTS
.split_whitespace()
.any(|a| reach(a) != Reach::Local)
);
}
#[test]
fn the_agent_is_named_before_the_spec_and_defaults_to_claude() {
let args = |s: &str| s.split_whitespace().map(String::from).collect::<Vec<_>>();
let a = args("box:/srv --model opus");
let (agent, rest) = split_agent(&a).expect("ok");
assert_eq!((agent.as_str(), rest), ("claude", &a[..]));
let a = args("--agent opencode box:~");
let (agent, rest) = split_agent(&a).expect("ok");
assert_eq!((agent.as_str(), rest), ("opencode", &a[2..]));
let a = args("--agent=opencode box:~");
assert_eq!(split_agent(&a).expect("ok").0, "opencode");
assert!(split_agent(&args("--agent")).is_err());
}
#[test]
fn a_shell_call_is_read_like_bash_reads_one() {
let args = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(
shell_call(&args(&["-c", "ls -la"])),
ShellCall::Command("ls -la")
);
assert_eq!(
shell_call(&args(&["-lc", "make"])),
ShellCall::Command("make")
);
assert_eq!(
shell_call(&args(&["-l", "-c", "make", "argv0"])),
ShellCall::Command("make")
);
assert_eq!(shell_call(&args(&[])), ShellCall::Interactive);
assert_eq!(shell_call(&args(&["-l"])), ShellCall::Interactive);
assert_eq!(
shell_call(&args(&["--login", "-i"])),
ShellCall::Interactive
);
assert_eq!(shell_call(&args(&["script.sh"])), ShellCall::Unknown);
assert_eq!(shell_call(&args(&["-c"])), ShellCall::Unknown);
assert_eq!(shell_call(&args(&["-x", "y"])), ShellCall::Unknown);
}
#[test]
fn opencodes_config_carries_the_shell_and_the_guard() {
let shell = Path::new("/run/user/1/cctop/sbx-shell-1/bash");
let fresh: serde_json::Value =
serde_json::from_str(&opencode_config(None, shell)).expect("json");
assert_eq!(fresh["shell"], "/run/user/1/cctop/sbx-shell-1/bash");
assert_eq!(fresh["permission"]["external_directory"], "deny");
let theirs = r#"{"model":"x","permission":{"bash":"ask","external_directory":"allow"}}"#;
let merged: serde_json::Value =
serde_json::from_str(&opencode_config(Some(theirs), shell)).expect("json");
assert_eq!(merged["model"], "x");
assert_eq!(merged["permission"]["bash"], "ask");
assert_eq!(merged["permission"]["external_directory"], "deny");
let junk: serde_json::Value =
serde_json::from_str(&opencode_config(Some("[1]"), shell)).expect("json");
assert_eq!(junk["permission"]["external_directory"], "deny");
}
#[test]
fn the_shell_shim_passes_its_arguments_through() {
let shim = write_shell_shim(Path::new("/bin/echo")).expect("shim");
let out = Command::new(&shim)
.args(["-c", "it's one arg"])
.output()
.expect("run");
assert_eq!(
String::from_utf8_lossy(&out.stdout),
"--sandbox-shell -c it's one arg\n"
);
let _ = std::fs::remove_dir_all(shim.parent().expect("dir"));
}
#[test]
fn a_bash_call_is_told_from_a_hook_by_its_closing_pwd() {
match classify(BASH_CALL) {
Call::Remote { head, cwd_file } => {
assert!(head.ends_with("eval 'ls -la' < /dev/null"), "{head}");
assert_eq!(cwd_file, "/tmp/claude-ab12-cwd");
}
other => panic!("{other:?}"),
}
let quoted = "eval 'x' && pwd -P >| '/tmp/claude dir-1-cwd'";
assert_eq!(
classify(quoted),
Call::Remote {
head: "eval 'x'",
cwd_file: "/tmp/claude dir-1-cwd".into()
}
);
assert_eq!(classify("cctop hook PreToolUse"), Call::Local);
assert_eq!(
classify("'/usr/bin/cctop' --sandbox-guard /srv"),
Call::Local
);
assert_eq!(classify(""), Call::Local);
assert_eq!(classify("echo pwd -P >| /tmp/x && echo done"), Call::Local);
assert_eq!(classify("eval 'x' && pwd -P >| relative"), Call::Unreadable);
assert_eq!(classify("eval 'x' ; pwd -P >| /tmp/c"), Call::Unreadable);
assert_eq!(
classify("eval 'x' && pwd -P >| '/tmp/a'\\''b'"),
Call::Unreadable
);
}
#[test]
fn the_directory_line_is_taken_out_and_nothing_else_is() {
let nonce = "@@N@@";
let run = |chunks: &[&[u8]]| {
let mut strip = Strip::new(nonce);
let mut out = Vec::new();
for chunk in chunks {
out.extend(strip.feed(chunk));
}
out.extend(strip.finish());
(String::from_utf8(out).expect("utf8"), strip.cwd)
};
assert_eq!(
run(&[b"warn\n\n@@N@@/srv/x\n"]),
("warn\n".into(), Some("/srv/x".into()))
);
assert_eq!(
run(&[b"no newline\n@@N@@/srv\n"]),
("no newline".into(), Some("/srv".into()))
);
assert_eq!(
run(&[b"a\n", b"\n@@", b"N", b"@@/sr", b"v\n"]),
("a\n".into(), Some("/srv".into()))
);
assert_eq!(run(&[b"x\n@@", b"Q"]), ("x\n@@Q".into(), None));
assert_eq!(run(&[b"tail\n@"]), ("tail\n@".into(), None));
assert_eq!(run(&[b"boom\n"]), ("boom\n".into(), None));
}
fn event(tool: &str, input: serde_json::Value) -> serde_json::Value {
serde_json::json!({"tool_name": tool, "tool_input": input, "cwd": "/srv/app"})
}
#[test]
fn the_file_tools_are_kept_inside_the_mount() {
let roots = [PathBuf::from("/srv/app")];
let own = [PathBuf::from("/home/me/.claude"), PathBuf::from("/tmp")];
let deny = |tool: &str, input| refusal(&event(tool, input), &roots, &own, "box");
use serde_json::json;
assert_eq!(
deny("Read", json!({"file_path": "/srv/app/src/x.rs"})),
None
);
assert_eq!(deny("Edit", json!({"file_path": "src/x.rs"})), None);
assert_eq!(deny("Glob", json!({"pattern": "**/*.rs"})), None);
assert_eq!(
deny("Grep", json!({"pattern": "fn", "path": "/srv/app/src"})),
None
);
assert_eq!(
deny("Write", json!({"file_path": "/home/me/.claude/plans/p.md"})),
None
);
assert_eq!(
deny("Read", json!({"file_path": "/tmp/claude-1000/t/x.output"})),
None
);
assert_eq!(deny("Bash", json!({"command": "cat /etc/hosts"})), None);
let reason = deny("Read", json!({"file_path": "/etc/hosts"})).expect("refused");
assert!(
reason.contains("/etc/hosts") && reason.contains("Bash"),
"{reason}"
);
assert!(deny("Read", json!({"file_path": "/srv/app/../other/x"})).is_some());
assert!(
deny("Read", json!({"file_path": "/srv/apple/x"})).is_some(),
"a prefix is not a parent"
);
assert!(deny("NotebookEdit", json!({"notebook_path": "/home/me/n.ipynb"})).is_some());
assert!(deny("Grep", json!({"pattern": "x", "path": "/var/log"})).is_some());
assert!(deny("Glob", json!({"pattern": "/etc/**/*.conf"})).is_some());
assert!(deny("Read", json!({"file_path": "/tmp/other"})).is_some());
}
#[test]
fn quoting_survives_a_shell() {
for text in ["plain", "with space", "it's", "", "$HOME `x` \\ \"q\""] {
let out = Command::new("sh")
.arg("-c")
.arg(format!("printf %s {}", sh_quote(text)))
.output()
.expect("sh");
assert_eq!(String::from_utf8_lossy(&out.stdout), text);
}
}
#[test]
fn the_mount_table_is_read_at_exactly_the_path() {
let table = "\
36 35 98:0 / / rw - ext4 /dev/sda rw
90 36 0:50 / /home/a\\040b rw,nosuid - fuse.sshfs box:/x rw
91 36 0:51 / /srv/app rw - fuseblk box:/srv/app rw";
assert_eq!(
mount_type_in(table, Path::new("/home/a b")).as_deref(),
Some("fuse.sshfs")
);
assert_eq!(
mount_type_in(table, Path::new("/srv/app")).as_deref(),
Some("fuseblk")
);
assert_eq!(mount_type_in(table, Path::new("/srv")), None);
}
#[test]
fn a_missing_mountpoint_is_made_and_a_full_one_refused() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path().join("a/b");
let made = prepare_mountpoint(&root, "fusermount3").expect("made");
assert_eq!(made, vec![root.clone(), dir.path().join("a")]);
std::fs::write(root.join("f"), "x").expect("write");
let err = prepare_mountpoint(&root, "fusermount3").expect_err("not empty");
assert!(err.to_string().contains("not empty"), "{err}");
}
#[test]
fn a_mountpoint_that_cannot_be_made_says_why() {
let err = prepare_mountpoint(Path::new("/proc/cctop-sandbox-test/x"), "fusermount3")
.expect_err("cannot be made");
let text = err.to_string();
assert!(
text.contains("could not create /proc/cctop-sandbox-test/x"),
"{text}"
);
assert!(!text.contains("sudo"), "{text}");
}
#[test]
fn the_probe_reads_what_the_host_said() {
let p = parse_probe(
"hostname=db1\nuname=Linux 6.8.0\nbash=/usr/bin/bash\nsetsid=/usr/bin/setsid\nroot=/home/f/x\nreadable=1\n",
);
assert!(p.readable && !p.writable);
let facts = p.facts();
assert!(facts.exists && facts.is_dir && facts.readable && !facts.writable);
assert_eq!(p.hostname, "db1");
assert_eq!(p.uname, "Linux 6.8.0");
assert_eq!(p.root, "/home/f/x");
let out = Command::new("sh")
.arg("-c")
.arg(probe_command("~"))
.env("HOME", "/")
.output()
.expect("sh");
assert_eq!(parse_probe(&String::from_utf8_lossy(&out.stdout)).root, "/");
}
#[test]
fn the_guard_is_installed_for_the_file_tools_alone() {
let exe = Path::new("/usr/bin/cctop");
let settings = guard_settings(exe, Path::new("/srv/my app"), None);
let v: serde_json::Value = serde_json::from_str(&settings).expect("json");
let entry = &v["hooks"]["PreToolUse"][0];
assert_eq!(entry["matcher"], FILE_TOOLS);
assert_eq!(
entry["hooks"][0]["command"],
"/usr/bin/cctop --sandbox-guard '/srv/my app'"
);
let map = PathMap::new(Path::new(L), Path::new(R));
let settings = guard_settings(exe, Path::new(L), map.as_ref());
let v: serde_json::Value = serde_json::from_str(&settings).expect("json");
assert_eq!(
v["hooks"]["PreToolUse"][0]["hooks"][0]["command"],
format!("/usr/bin/cctop --sandbox-guard {L} --remote {R}")
);
}
const L: &str = "/home/flo/.cache/cctop/remote/procdb/home/florian.leprat";
const R: &str = "/home/florian.leprat";
fn map() -> PathMap {
PathMap::new(Path::new(L), Path::new(R)).expect("two names")
}
#[test]
fn one_path_needs_no_map() {
assert_eq!(PathMap::new(Path::new(R), Path::new(R)), None);
assert!(PathMap::new(Path::new(L), Path::new(R)).is_some());
}
#[test]
fn a_command_is_given_the_hosts_name() {
let m = map();
let out = |text: &str| m.command_to_remote(text).into_owned();
assert_eq!(
out(&format!("cat {L}/src/x.rs")),
format!("cat {R}/src/x.rs")
);
assert_eq!(out(&format!("cd {L}")), format!("cd {R}"));
assert_eq!(
out(&format!("eval 'ls \"{L}/a b\"' && x='{L}'")),
format!("eval 'ls \"{R}/a b\"' && x='{R}'")
);
assert_eq!(
out(&format!("PATH={L}/bin:{L}/node/bin:$PATH make --dir={L}")),
format!("PATH={R}/bin:{R}/node/bin:$PATH make --dir={R}")
);
assert_eq!(
out(&format!("diff {L}/a {L}/b")),
format!("diff {R}/a {R}/b")
);
assert_eq!(out("git status"), "git status");
assert!(matches!(
m.command_to_remote("git status"),
std::borrow::Cow::Borrowed(_)
));
for other in [
format!("{L}x/y"),
format!("{L}.bak"),
format!("{L}-old"),
format!("/mnt{L}"),
format!("x{L}"),
] {
assert_eq!(out(&other), other, "{other}");
}
}
#[test]
fn a_path_boundary_is_a_whole_name() {
assert_eq!(swap("/x/ab /x/a /x/a/b", "/x/a", "/R"), "/x/ab /R /R/b");
assert_eq!(swap("cd /x/a;ls", "/x/a", "/R"), "cd /R;ls");
assert_eq!(swap("(/x/a)", "/x/a", "/R"), "(/R)");
assert_eq!(swap("/a/a/a /a/a", "/a/a", "/R"), "/R/a /R");
assert_eq!(swap("ls /m/src /m", "/m", "/"), "ls /src /");
assert_eq!(swap("ls /", "/", "/R"), "ls /");
}
#[test]
fn a_path_goes_across_by_components() {
let m = map();
assert_eq!(m.to_remote(Path::new(L)), Path::new(R));
assert_eq!(
m.to_remote(&Path::new(L).join("src")),
Path::new(R).join("src")
);
assert_eq!(
m.to_local(&Path::new(R).join("a/b")),
Path::new(L).join("a/b")
);
assert_eq!(m.to_remote(Path::new("/tmp/x")), Path::new("/tmp/x"));
assert_eq!(
m.to_local(Path::new("/home/florian.leprat2")),
Path::new("/home/florian.leprat2")
);
assert_eq!(m.to_local(Path::new("/etc")), Path::new("/etc"));
}
#[test]
fn a_call_is_translated_out_and_its_directory_back() {
let m = map();
let command = format!("eval 'cat {L}/README'");
let (head, cwd) = outbound(&command, &Path::new(L).join("src"), Some(&m));
assert_eq!(head, format!("eval 'cat {R}/README'"));
assert_eq!(cwd, Path::new(R).join("src"));
assert_eq!(
inbound(format!("{R}/src/sub"), Some(&m)),
format!("{L}/src/sub")
);
assert_eq!(inbound("/var/log".into(), Some(&m)), "/var/log");
let (head, cwd) = outbound("eval ls", Path::new(R), None);
assert_eq!((head.as_ref(), cwd.as_path()), ("eval ls", Path::new(R)));
assert_eq!(inbound(R.into(), None), R);
}
#[test]
fn the_sandbox_value_splits_into_host_and_path() {
assert_eq!(path_of("procdb:/home/f"), Some("/home/f"));
assert_eq!(path_of("[::1]:/srv"), Some("/srv"));
assert_eq!(path_of("procdb:/a:b"), Some("/a:b"));
assert_eq!(path_of("procdb"), None);
assert_eq!(path_of("procdb:"), None);
}
fn guard_says(tool: &str, input: serde_json::Value) -> Option<serde_json::Value> {
let event = serde_json::json!({"tool_name": tool, "tool_input": input, "cwd": L});
let own = [PathBuf::from("/home/flo/.claude"), PathBuf::from("/tmp")];
answer(&event, &[PathBuf::from(L)], &own, "procdb", Some(&map()))
}
#[test]
fn the_guard_points_a_host_path_at_the_mount() {
use serde_json::json;
let rewritten = |tool: &str, input: serde_json::Value, want: serde_json::Value| {
assert_eq!(
guard_says(tool, input),
Some(json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"updatedInput": want,
},
})),
"{tool}"
);
};
rewritten(
"Read",
json!({"file_path": format!("{R}/src/x.rs"), "offset": 10, "limit": 5}),
json!({"file_path": format!("{L}/src/x.rs"), "offset": 10, "limit": 5}),
);
rewritten(
"Edit",
json!({"file_path": format!("{R}/a"), "old_string": "x", "new_string": "y"}),
json!({"file_path": format!("{L}/a"), "old_string": "x", "new_string": "y"}),
);
rewritten(
"Write",
json!({"file_path": format!("{R}/new.txt"), "content": "hi"}),
json!({"file_path": format!("{L}/new.txt"), "content": "hi"}),
);
rewritten(
"MultiEdit",
json!({"file_path": format!("{R}/a"), "edits": [{"old_string": "x", "new_string": "y"}]}),
json!({"file_path": format!("{L}/a"), "edits": [{"old_string": "x", "new_string": "y"}]}),
);
rewritten(
"NotebookEdit",
json!({"notebook_path": format!("{R}/n.ipynb"), "new_source": "1"}),
json!({"notebook_path": format!("{L}/n.ipynb"), "new_source": "1"}),
);
rewritten(
"Glob",
json!({"pattern": format!("{R}/src/**/*.rs")}),
json!({"pattern": format!("{L}/src/**/*.rs")}),
);
rewritten(
"Glob",
json!({"pattern": "*.rs", "path": R}),
json!({"pattern": "*.rs", "path": L}),
);
rewritten(
"Grep",
json!({"pattern": "fn main", "path": format!("{R}/src"), "glob": "*.rs"}),
json!({"pattern": "fn main", "path": format!("{L}/src"), "glob": "*.rs"}),
);
rewritten(
"Read",
json!({"file_path": "/home/./florian.leprat/x"}),
json!({"file_path": format!("{L}/x")}),
);
}
#[test]
fn the_guard_still_refuses_what_is_not_on_the_mount() {
use serde_json::json;
assert_eq!(
guard_says("Read", json!({"file_path": format!("{L}/x")})),
None
);
assert_eq!(guard_says("Read", json!({"file_path": "src/x"})), None);
assert_eq!(guard_says("Glob", json!({"pattern": "**/*.rs"})), None);
assert_eq!(
guard_says(
"Write",
json!({"file_path": "/home/flo/.claude/plans/p.md"})
),
None
);
assert_eq!(
guard_says("Bash", json!({"command": format!("cat {R}/x")})),
None
);
let denied = |input| {
guard_says("Read", input)
.and_then(|a| {
a["hookSpecificOutput"]["permissionDecision"]
.as_str()
.map(str::to_string)
})
.unwrap_or_default()
};
assert_eq!(denied(json!({"file_path": "/etc/hosts"})), "deny");
assert_eq!(
denied(json!({"file_path": "/home/florian.leprat2/x"})),
"deny"
);
assert_eq!(
denied(json!({"file_path": format!("{R}/../other/x")})),
"deny"
);
}
#[test]
fn the_hosts_root_is_translated_without_swallowing_this_machine() {
use serde_json::json;
let local = "/home/flo/.cache/cctop/remote/procdb";
let map = PathMap::new(Path::new(local), Path::new("/")).expect("map");
let own = [PathBuf::from("/home/flo/.claude"), PathBuf::from("/tmp")];
let say = |path: &str| {
let event =
json!({"tool_name": "Read", "tool_input": {"file_path": path}, "cwd": local});
answer(&event, &[PathBuf::from(local)], &own, "procdb", Some(&map))
.map(|a| a["hookSpecificOutput"]["updatedInput"]["file_path"].clone())
};
assert_eq!(say("/etc/hosts"), Some(json!(format!("{local}/etc/hosts"))));
assert_eq!(say(&format!("{local}/etc/hosts")), None);
assert_eq!(say("/home/flo/.claude/x"), None);
assert_eq!(
map.command_to_remote(&format!("cat {local}/etc/hosts")),
"cat /etc/hosts"
);
}
fn call(dir: &Path, command: &str, cwd: &Path) -> (i32, Option<String>, String) {
let ssh = fake_ssh(dir);
let nonce = nonce();
let mut cmd = Command::new(ssh);
cmd.args(mux_args(Path::new("/nonexistent.sock")))
.arg("box")
.arg("--")
.arg(remote_line(command, &nonce, &cwd.to_string_lossy()))
.stdout(Stdio::null());
let mut err = Vec::new();
let (code, dir) = run_remote(cmd, &nonce, &mut err).expect("ran");
(code, dir, String::from_utf8_lossy(&err).into_owned())
}
#[test]
fn a_call_returns_its_code_its_stderr_and_where_it_ended() {
let tmp = tempfile::tempdir().expect("tempdir");
let work = tmp.path().join("work");
std::fs::create_dir_all(work.join("sub")).expect("mkdir");
let work = work.canonicalize().expect("canonical");
let (code, dir, err) = call(tmp.path(), "eval 'echo oops >&2; cd sub'", &work);
assert_eq!(code, 0);
assert_eq!(err, "oops\n", "only the command's own stderr");
assert_eq!(
dir.as_deref(),
Some(work.join("sub").to_str().expect("utf8"))
);
let (code, dir, _) = call(tmp.path(), "eval 'cd sub; exit 7'", &work);
assert_eq!(code, 7);
assert_eq!(dir, None);
let (code, _, err) = call(tmp.path(), "eval true", &tmp.path().join("gone"));
assert_eq!(code, 1);
assert!(err.contains("is not a directory"), "{err}");
}
fn holding(token: &str) -> Vec<u32> {
let mut found = Vec::new();
for entry in std::fs::read_dir("/proc").expect("proc").flatten() {
let Ok(pid) = entry.file_name().to_string_lossy().parse::<u32>() else {
continue;
};
if pid == std::process::id() {
continue;
}
let cmdline = std::fs::read(entry.path().join("cmdline")).unwrap_or_default();
if String::from_utf8_lossy(&cmdline).contains(token) {
found.push(pid);
}
}
found
}
#[test]
fn nothing_from_a_call_outlives_it() {
let tmp = tempfile::tempdir().expect("tempdir");
let token = format!("cctop-sbx-leak-{}", std::process::id());
for _ in 0..3 {
let (code, _, _) = call(
tmp.path(),
&format!("eval ': {token}; echo hi'"),
tmp.path(),
);
assert_eq!(code, 0);
}
assert_eq!(
holding(&token),
Vec::<u32>::new(),
"a helper outlived its call"
);
}
#[test]
fn closing_the_channel_stops_the_command_on_the_host() {
let tmp = tempfile::tempdir().expect("tempdir");
let sleeper = tmp
.path()
.join(format!("sleep-cctop-sbx-orphan-{}", std::process::id()));
std::fs::copy("/bin/sleep", &sleeper).expect("copy sleep");
let token = sleeper.to_string_lossy().into_owned();
let ssh = fake_ssh(tmp.path());
let mut child = Command::new(ssh)
.args(mux_args(Path::new("/nonexistent.sock")))
.arg("box")
.arg("--")
.arg(remote_line(
&format!("eval '{token} 30'"),
&nonce(),
&tmp.path().to_string_lossy(),
))
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn");
let sleeping = || holding(&format!("{token}\0"));
let started = Instant::now();
while sleeping().is_empty() {
assert!(started.elapsed() < Duration::from_secs(10), "never started");
std::thread::sleep(Duration::from_millis(20));
}
drop(child.stdin.take());
let started = Instant::now();
loop {
if child.try_wait().expect("wait").is_some() && holding(&token).is_empty() {
break;
}
assert!(
started.elapsed() < Duration::from_secs(10),
"the command outlived its channel: {:?}",
holding(&token)
);
std::thread::sleep(Duration::from_millis(20));
}
}
}