use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
const SHIMMED_SHELLS: [&str; 3] = ["sh", "bash", "zsh"];
pub fn shim_dir(termaxa_home: &Path) -> PathBuf {
termaxa_home.join("shims")
}
#[cfg(unix)]
pub fn install_shims(termaxa_home: &Path, termaxa_bin: &Path) -> Result<PathBuf> {
let path = std::env::var_os("PATH").unwrap_or_default();
install_shims_on(termaxa_home, termaxa_bin, &path)
}
#[cfg(unix)]
pub fn install_shims_on(
termaxa_home: &Path,
termaxa_bin: &Path,
path: &std::ffi::OsStr,
) -> Result<PathBuf> {
use std::os::unix::fs::PermissionsExt;
let dir = shim_dir(termaxa_home);
std::fs::create_dir_all(&dir)
.with_context(|| format!("cannot create shim directory {}", dir.display()))?;
let mut perm = std::fs::metadata(&dir)?.permissions();
perm.set_mode(0o755);
std::fs::set_permissions(&dir, perm)?;
for shell in SHIMMED_SHELLS {
let Some(real) = real_shell_on(shell, &dir, path) else {
let _ = std::fs::remove_file(dir.join(shell));
continue;
};
let path = dir.join(shell);
let script = format!(
r#"#!/bin/sh
# termaxa shim - generated, do not edit.
# A `-c` string, alone or in a cluster such as `-lc` or `-ec`, is routed
# through the gate with the shell's other options intact; anything else
# (a script file, an interactive shell) is handed to the real shell unchanged.
expect_string=""
skip_next=""
for a in "$@"; do
if [ -n "$skip_next" ]; then
skip_next=""
continue
fi
if [ -n "$expect_string" ]; then
# Options may follow -c (`zsh -c -l "..."` is Claude Code's spelling);
# the string is the first operand after them.
case "$a" in
--) break ;;
-o) skip_next=1 ;;
-*) ;;
"") break ;;
*) exec {bin} run -- {shell} "$@" ;;
esac
continue
fi
case "$a" in
--) break ;;
-) break ;;
--*) ;;
-o) skip_next=1 ;;
-*c*) expect_string=1 ;;
-*) ;;
*) break ;;
esac
done
exec {real} "$@"
"#,
bin = termaxa_bin.display(),
shell = shell,
real = real.display(),
);
std::fs::write(&path, script)
.with_context(|| format!("cannot write shim {}", path.display()))?;
let mut p = std::fs::metadata(&path)?.permissions();
p.set_mode(0o755);
std::fs::set_permissions(&path, p)?;
}
Ok(dir)
}
#[cfg(unix)]
fn real_shell_on(
shell: &str,
shim_dir: &std::path::Path,
path: &std::ffi::OsStr,
) -> Option<std::path::PathBuf> {
for d in std::env::split_paths(path) {
if d == shim_dir {
continue;
}
let candidate = d.join(shell);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
#[cfg(not(unix))]
pub fn install_shims(termaxa_home: &Path, _termaxa_bin: &Path) -> Result<PathBuf> {
anyhow::bail!(
"termaxa wrap is Unix-only in v0.16. Windows has no $SHELL convention, so \
shims for {shells} under {dir} would not be consulted the way they are on \
Unix, and guessing at an equivalent is worse than saying so. Use hook mode, \
which is fully supported on Windows.",
shells = SHIMMED_SHELLS.join("/"),
dir = shim_dir(termaxa_home).display(),
)
}
pub fn outside_shims(
program: &str,
path: Option<&std::ffi::OsStr>,
termaxa_home: &Path,
) -> (std::ffi::OsString, std::ffi::OsString) {
let shims = shim_dir(termaxa_home);
let same_dir = |entry: &str| -> bool {
let e = Path::new(entry);
e == shims
|| match (e.canonicalize(), shims.canonicalize()) {
(Ok(a), Ok(b)) => a == b,
_ => false,
}
};
let kept: Vec<String> = path
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default()
.split(path_separator())
.filter(|entry| !entry.is_empty() && !same_dir(entry))
.map(str::to_string)
.collect();
let stripped: std::ffi::OsString = kept.join(path_separator()).into();
let bare = !program.contains('/') && !program.contains('\\');
let resolved = if bare {
kept.iter()
.map(|dir| Path::new(dir).join(program))
.find(|candidate| is_executable_file(candidate))
.map(|p| p.into_os_string())
.unwrap_or_else(|| program.into())
} else {
program.into()
};
(resolved, stripped)
}
#[cfg(unix)]
fn is_executable_file(p: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(p)
.map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
fn is_executable_file(p: &Path) -> bool {
std::fs::metadata(p).map(|m| m.is_file()).unwrap_or(false)
}
pub(crate) fn agent_shell(shim_dir: &Path) -> PathBuf {
for name in ["zsh", "bash"] {
let shim = shim_dir.join(name);
if shim.is_file() {
return shim;
}
}
shim_dir.join("sh")
}
pub(crate) fn chosen_shell(shim_dir: &Path, operator: Option<&Path>) -> (PathBuf, Option<PathBuf>) {
if let Some(op) = operator {
if op.parent() == Some(shim_dir) && op.is_file() {
return (op.to_path_buf(), None);
}
return (agent_shell(shim_dir), Some(op.to_path_buf()));
}
(agent_shell(shim_dir), None)
}
pub fn run(argv: &[String], termaxa_home: &Path) -> Result<i32> {
if argv.is_empty() {
anyhow::bail!("nothing to wrap: termaxa wrap -- <command>");
}
let bin = std::env::current_exe().context("cannot locate the termaxa binary")?;
let dir = install_shims(termaxa_home, &bin)?;
let existing = std::env::var("PATH").unwrap_or_default();
let path = format!("{}{}{}", dir.display(), path_separator(), existing);
let operator = std::env::var_os("CLAUDE_CODE_SHELL").map(PathBuf::from);
let (shell, overridden) = chosen_shell(&dir, operator.as_deref());
if let Some(prev) = overridden {
eprintln!(
"termaxa wrap: CLAUDE_CODE_SHELL was {}; set to {} so Claude Code's shell is the gate",
prev.display(),
shell.display()
);
}
let mut cmd = std::process::Command::new(&argv[0]);
cmd.args(&argv[1..])
.env("PATH", path)
.env("SHELL", &shell)
.env("CLAUDE_CODE_SHELL", &shell)
.env("TERMAXA_WRAPPED", "1");
if let Some(sock) = crate::supervise::endpoint() {
cmd.env(crate::supervise::SOCKET_ENV, &sock);
}
let status = cmd
.status()
.with_context(|| format!("cannot launch {}", argv[0]))?;
Ok(status.code().unwrap_or(1))
}
fn path_separator() -> &'static str {
if cfg!(windows) {
";"
} else {
":"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::TempTree;
#[cfg(unix)]
const SEARCH: &str = "/usr/local/bin:/usr/bin:/bin";
#[cfg(unix)]
fn shims(home: &Path) -> PathBuf {
install_shims_on(
home,
Path::new("/usr/bin/termaxa"),
std::ffi::OsStr::new(SEARCH),
)
.unwrap()
}
#[cfg(unix)]
fn real(shell: &str, dir: &Path) -> Option<PathBuf> {
real_shell_on(shell, dir, std::ffi::OsStr::new(SEARCH))
}
#[cfg(unix)]
#[test]
fn the_agent_is_told_to_use_the_shim_for_the_shell_it_would_have_chosen() {
let t = TempTree::new("wrap-agent-shell");
let fake = t.path().join("fake-shims");
std::fs::create_dir_all(&fake).unwrap();
for name in ["sh", "bash", "zsh"] {
std::fs::write(fake.join(name), "#!/bin/sh\n").unwrap();
}
assert_eq!(agent_shell(&fake), fake.join("zsh"));
std::fs::remove_file(fake.join("zsh")).unwrap();
assert_eq!(agent_shell(&fake), fake.join("bash"));
std::fs::remove_file(fake.join("bash")).unwrap();
assert_eq!(agent_shell(&fake), fake.join("sh"));
let dir = shims(t.path());
let want = if dir.join("zsh").is_file() {
dir.join("zsh")
} else {
dir.join("bash")
};
assert_eq!(agent_shell(&dir), want);
}
#[cfg(unix)]
#[test]
fn an_operators_shim_is_kept_and_anything_else_is_overridden_and_said() {
let t = TempTree::new("wrap-chosen-shell");
let fake = t.path().join("fake-shims");
std::fs::create_dir_all(&fake).unwrap();
for name in ["sh", "bash", "zsh"] {
std::fs::write(fake.join(name), "#!/bin/sh\n").unwrap();
}
assert_eq!(chosen_shell(&fake, None), (fake.join("zsh"), None));
assert_eq!(
chosen_shell(&fake, Some(&fake.join("bash"))),
(fake.join("bash"), None),
"the operator's own shim is kept"
);
assert_eq!(
chosen_shell(&fake, Some(&fake.join("sh"))),
(fake.join("sh"), None)
);
let outside = Path::new("/opt/homebrew/bin/bash");
assert_eq!(
chosen_shell(&fake, Some(outside)),
(fake.join("zsh"), Some(outside.to_path_buf())),
"outside the shim directory: replaced and reported"
);
let missing = fake.join("fish");
assert_eq!(
chosen_shell(&fake, Some(&missing)),
(fake.join("zsh"), Some(missing.clone())),
"a shim that does not exist is not a shim"
);
}
#[cfg(unix)]
#[test]
fn shims_are_written_executable_and_not_writable_by_others() {
use std::os::unix::fs::PermissionsExt;
let t = TempTree::new("wrap-shims");
let home = t.path();
let dir = shims(home);
for shell in SHIMMED_SHELLS {
let p = dir.join(shell);
if real(shell, &dir).is_none() {
assert!(
!p.exists(),
"no shim for a shell that is not installed: {shell}"
);
continue;
}
assert!(p.exists(), "{shell} shim exists");
let mode = std::fs::metadata(&p).unwrap().permissions().mode();
assert_eq!(mode & 0o111, 0o111, "{shell} is executable");
assert_eq!(
mode & 0o022,
0,
"{shell} must not be group- or world-writable: a writable file on \
PATH is a way to run code, not a way to gate it (#51)"
);
}
}
#[cfg(unix)]
#[test]
fn the_shim_routes_dash_c_and_passes_everything_else_through() {
let t = TempTree::new("wrap-script");
let dir = shims(t.path());
let script = std::fs::read_to_string(dir.join("sh")).unwrap();
assert!(
script.contains("/usr/bin/termaxa run --"),
"a -c command goes through the runner: {script}"
);
let real = real("sh", &dir).expect("sh exists on any unix test machine");
assert!(
script.contains(&format!("exec {} \"$@\"", real.display())),
"anything else reaches the real shell by its resolved path: {script}"
);
assert!(
script.contains("-o) skip_next=1 ;;")
&& script.contains("exec /usr/bin/termaxa run --"),
"{script}"
);
assert!(
script.contains("exec "),
"exec rather than a nested shell, so the shim adds no process: {script}"
);
}
#[cfg(unix)]
#[test]
fn an_approved_command_runs_outside_the_shims() {
use std::os::unix::fs::PermissionsExt;
let t = TempTree::new("wrap-outside");
let dir = shims(t.path());
let real = t.dir("realbin");
std::fs::write(real.join("sh"), "#!/bin/sh\nexit 0\n").unwrap();
let mut p = std::fs::metadata(real.join("sh")).unwrap().permissions();
p.set_mode(0o755);
std::fs::set_permissions(real.join("sh"), p).unwrap();
let path = format!("{}:{}:/nonexistent", dir.display(), real.display());
let (program, stripped) = outside_shims("sh", Some(std::ffi::OsStr::new(&path)), t.path());
assert_eq!(
program,
real.join("sh").into_os_string(),
"the bare name resolves past the shim to the real shell"
);
let stripped = stripped.to_string_lossy().into_owned();
assert!(
!stripped.contains(&dir.display().to_string()),
"the shim directory is out of the child's PATH: {stripped}"
);
assert!(
stripped.starts_with(&real.display().to_string()),
"{stripped}"
);
let path = format!("{}/:{}", dir.display(), real.display());
let (_, stripped) = outside_shims("sh", Some(std::ffi::OsStr::new(&path)), t.path());
assert!(
!stripped.to_string_lossy().contains("shims"),
"{stripped:?}"
);
let (program, _) = outside_shims("/bin/sh", Some(std::ffi::OsStr::new(&path)), t.path());
assert_eq!(program, std::ffi::OsString::from("/bin/sh"));
let (program, _) = outside_shims(
"no-such-program-tmx",
Some(std::ffi::OsStr::new(&path)),
t.path(),
);
assert_eq!(program, std::ffi::OsString::from("no-such-program-tmx"));
let (_, same) = outside_shims("sh", Some(std::ffi::OsStr::new("/usr/bin:/bin")), t.path());
assert_eq!(same, std::ffi::OsString::from("/usr/bin:/bin"));
}
#[cfg(unix)]
#[test]
fn an_absolute_path_shell_is_outside_what_a_path_shim_can_reach() {
let t = TempTree::new("wrap-residue");
let dir = shims(t.path());
assert!(dir.join("sh").exists());
assert!(
!dir.join("bin").exists(),
"the shim dir shadows names on PATH, not absolute paths"
);
}
#[test]
fn the_shim_directory_is_outside_the_project() {
let t = TempTree::new("wrap-location");
let home = t.path();
let dir = shim_dir(home);
assert!(dir.starts_with(home), "{}", dir.display());
assert!(
!dir.to_string_lossy().contains(".termaxa/policy"),
"not beside the policy the agent may read"
);
}
}