use std::ffi::OsStr;
use std::fs;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::Context;
use serde::Deserialize;
use super::create::{JAIL_SUBPATH, WORKTREES_SUBDIR};
use super::jail::{
Backend, Decision, JailPolicy, ResolvedMac, decide_bash, decide_write, resolve_target,
seatbelt_profile, validate_policy,
};
#[cfg(target_os = "macos")]
use super::jail::{RealEnv, resolve_inputs, seatbelt_backend};
const ENV_PROJECT_DIR: &str = "CLAUDE_PROJECT_DIR";
const TOOL_BASH: &str = "Bash";
const TOOL_EDIT: &str = "Edit";
const TOOL_WRITE: &str = "Write";
const HOOK_EVENT: &str = "PreToolUse";
const DECISION_DENY: &str = "deny";
const DECISION_ALLOW: &str = "allow";
const REASON_PREFIX: &str = "worktree-jail: ";
#[cfg_attr(
all(target_os = "macos", not(test)),
expect(dead_code, reason = "Linux prod arm + arm-neutral decide() tests only")
)]
const BWRAP_BIN: &str = "bwrap";
const REALPATH_BIN: &str = "realpath";
const REALPATH_MISSING_FLAG: &str = "-m";
#[cfg(not(target_os = "macos"))]
const ENV_PATH: &str = "PATH";
#[cfg_attr(
all(target_os = "macos", not(test)),
expect(dead_code, reason = "Linux prod arm + arm-neutral decide() tests only")
)]
pub(crate) const REASON_NO_BWRAP: &str = "bwrap-unavailable";
const REASON_BACKEND_UNUSED: &str = "backend-unused-on-write-path";
pub(crate) const REASON_PROFILE_WRITE_FAILED: &str = "seatbelt-profile-write-failed";
#[derive(Debug, Default, Deserialize)]
struct PreToolUseInput {
#[serde(default)]
agent_id: Option<String>,
#[serde(default)]
cwd: Option<String>,
#[serde(default)]
tool_name: Option<String>,
#[serde(default)]
tool_input: ToolInput,
}
#[derive(Debug, Default, Deserialize)]
struct ToolInput {
#[serde(default)]
command: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
file_path: Option<String>,
}
fn decide(
input: &PreToolUseInput,
cwd: &Path,
cwd_is_project_worktree: bool,
real: Option<&Path>,
backend: &Backend,
policy: &JailPolicy,
) -> Decision {
let target = resolve_target(input.agent_id.as_deref(), cwd, cwd_is_project_worktree);
match input.tool_name.as_deref() {
Some(TOOL_BASH) => {
let cmd = input.tool_input.command.as_deref().unwrap_or_default();
let desc = input.tool_input.description.as_deref().unwrap_or_default();
decide_bash(&target, cmd, desc, policy, backend)
}
Some(TOOL_EDIT | TOOL_WRITE) => decide_write(&target, real, policy),
_ => Decision::PassThrough,
}
}
fn render(decision: &Decision) -> Option<String> {
match decision {
Decision::PassThrough => None,
Decision::Deny { reason } => Some(
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": HOOK_EVENT,
"permissionDecision": DECISION_DENY,
"permissionDecisionReason": format!("{REASON_PREFIX}{reason}"),
}
})
.to_string(),
),
Decision::WrapBash {
command,
description,
} => Some(
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": HOOK_EVENT,
"permissionDecision": DECISION_ALLOW,
"updatedInput": { "command": command, "description": description },
}
})
.to_string(),
),
}
}
fn project_anchor() -> Option<PathBuf> {
let raw = std::env::var_os(ENV_PROJECT_DIR)?;
fs::canonicalize(PathBuf::from(raw)).ok()
}
fn cwd_is_project_worktree(cwd: &Path) -> bool {
if !matches!(super::shared::is_linked_worktree(cwd), Ok(true)) {
return false;
}
let Some(anchor) = project_anchor() else {
return false;
};
match (
super::shared::common_git_dir(cwd),
super::shared::common_git_dir(&anchor),
) {
(Ok(cwd_common), Ok(anchor_common)) => cwd_common == anchor_common,
_ => false,
}
}
fn canonicalize_missing(cwd: &Path, file_path: &str) -> Option<PathBuf> {
let raw = Path::new(file_path);
let abs = if raw.is_absolute() {
raw.to_path_buf()
} else {
cwd.join(raw)
};
let out = Command::new(REALPATH_BIN)
.arg(REALPATH_MISSING_FLAG)
.arg(&abs)
.output()
.ok()?;
if !out.status.success() {
return None;
}
let resolved = String::from_utf8(out.stdout).ok()?;
let trimmed = resolved.trim_end_matches('\n');
(!trimmed.is_empty()).then(|| PathBuf::from(trimmed))
}
fn resolve_provisioned_policy(cwd: &Path) -> JailPolicy {
let Some(name) = cwd.file_name().and_then(OsStr::to_str) else {
return JailPolicy::default();
};
let Some(parent) = cwd.parent() else {
return JailPolicy::default();
};
if parent.file_name() != Some(OsStr::new(WORKTREES_SUBDIR)) {
return JailPolicy::default();
}
let Some(main_root) = parent.parent() else {
return JailPolicy::default();
};
load_policy(main_root, name)
}
fn load_policy(main_root: &Path, name: &str) -> JailPolicy {
let file = main_root.join(JAIL_SUBPATH).join(format!("{name}.toml"));
let Ok(body) = fs::read_to_string(&file) else {
return JailPolicy::default(); };
let Ok(mut policy) = JailPolicy::from_toml_str(&body) else {
return JailPolicy::default(); };
let mut canon = Vec::with_capacity(policy.extra_rw.len());
for p in &policy.extra_rw {
let Ok(real) = fs::canonicalize(p) else {
return JailPolicy::default();
};
canon.push(real);
}
policy.extra_rw = canon;
if validate_policy(&policy, main_root).is_err() {
return JailPolicy::default(); }
policy
}
#[cfg(not(target_os = "macos"))]
pub(crate) fn have_bwrap() -> bool {
let Some(path) = std::env::var_os(ENV_PATH) else {
return false;
};
std::env::split_paths(&path).any(|dir| dir.join(BWRAP_BIN).is_file())
}
#[cfg(not(target_os = "macos"))]
fn probe_backend(_cwd: &Path) -> Backend {
if have_bwrap() {
Backend::Bwrap
} else {
Backend::Deny {
reason: REASON_NO_BWRAP.to_string(),
}
}
}
#[cfg(target_os = "macos")]
fn probe_backend(cwd: &Path) -> Backend {
let main_root = project_anchor().unwrap_or_default();
let env = RealEnv { main_root };
seatbelt_backend(resolve_inputs(cwd, &env.main_root, &env))
}
pub(crate) fn write_seatbelt_profile(resolved: &ResolvedMac) -> io::Result<()> {
#[expect(clippy::disallowed_methods, reason = "runtime seatbelt profile")]
fs::write(&resolved.profile_path, seatbelt_profile(resolved))
}
fn materialize_seatbelt_profile(backend: &Backend, decision: Decision) -> Decision {
let Backend::Seatbelt(resolved) = backend else {
return decision; };
let Decision::WrapBash { .. } = &decision else {
return decision; };
match write_seatbelt_profile(resolved) {
Ok(()) => decision,
Err(_e) => Decision::Deny {
reason: REASON_PROFILE_WRITE_FAILED.to_string(),
},
}
}
pub(crate) fn run_pretooluse() -> anyhow::Result<()> {
let mut raw = String::new();
let _read = io::stdin().read_to_string(&mut raw);
let input: PreToolUseInput = serde_json::from_str(&raw).unwrap_or_default();
if input.agent_id.is_none() {
return Ok(());
}
let cwd = input
.cwd
.as_deref()
.and_then(|c| fs::canonicalize(c).ok())
.unwrap_or_default();
let is_project_worktree = cwd_is_project_worktree(&cwd);
let backend = match input.tool_name.as_deref() {
Some(TOOL_BASH) => probe_backend(&cwd),
_ => Backend::Deny {
reason: REASON_BACKEND_UNUSED.to_string(),
},
};
let policy = resolve_provisioned_policy(&cwd);
let real = match input.tool_name.as_deref() {
Some(TOOL_EDIT | TOOL_WRITE) => input
.tool_input
.file_path
.as_deref()
.and_then(|fp| canonicalize_missing(&cwd, fp)),
_ => None,
};
let decision = decide(
&input,
&cwd,
is_project_worktree,
real.as_deref(),
&backend,
&policy,
);
let decision = materialize_seatbelt_profile(&backend, decision);
if let Some(json) = render(&decision) {
writeln!(io::stdout(), "{json}").context("emit hook decision")?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const WT: &str = "/home/u/proj/.worktrees/agent-abc";
fn input(agent: Option<&str>, tool: &str) -> PreToolUseInput {
PreToolUseInput {
agent_id: agent.map(str::to_string),
cwd: Some(WT.to_string()),
tool_name: Some(tool.to_string()),
tool_input: ToolInput::default(),
}
}
fn bash(agent: Option<&str>, cmd: &str) -> PreToolUseInput {
let mut i = input(agent, TOOL_BASH);
i.tool_input.command = Some(cmd.to_string());
i.tool_input.description = Some("run it".to_string());
i
}
fn parse(json: &str) -> serde_json::Value {
serde_json::from_str(json).expect("emitted JSON parses")
}
#[test]
fn bash_in_project_worktree_wraps_via_updated_input() {
let d = decide(
&bash(Some("a1"), "echo hi"),
Path::new(WT),
true,
None,
&Backend::Bwrap,
&JailPolicy::default(),
);
let json = render(&d).expect("wrap emits JSON");
let v = parse(&json);
let out = &v["hookSpecificOutput"];
assert_eq!(out["hookEventName"], HOOK_EVENT);
assert_eq!(out["permissionDecision"], DECISION_ALLOW);
let wrapped = out["updatedInput"]["command"]
.as_str()
.expect("command string");
assert!(wrapped.contains(BWRAP_BIN), "wrapped in bwrap: {wrapped}");
assert!(
wrapped.contains(WT),
"jail bound to the worktree: {wrapped}"
);
assert!(
!wrapped.contains("echo hi"),
"original command is opaque (base64), not literal: {wrapped}"
);
assert_eq!(out["updatedInput"]["description"], "run it");
}
#[test]
fn write_escaping_worktree_denies() {
let d = decide(
&input(Some("a1"), TOOL_WRITE),
Path::new(WT),
true,
Some(Path::new("/etc/passwd")),
&Backend::Bwrap,
&JailPolicy::default(),
);
let v = parse(&render(&d).expect("deny emits JSON"));
let out = &v["hookSpecificOutput"];
assert_eq!(out["permissionDecision"], DECISION_DENY);
let reason = out["permissionDecisionReason"].as_str().unwrap();
assert!(reason.starts_with(REASON_PREFIX), "prefixed: {reason}");
assert!(reason.contains("escapes-worktree"), "reason: {reason}");
}
#[test]
fn write_inside_worktree_passes_through() {
let d = decide(
&input(Some("a1"), TOOL_WRITE),
Path::new(WT),
true,
Some(&Path::new(WT).join("src/lib.rs")),
&Backend::Bwrap,
&JailPolicy::default(),
);
assert_eq!(d, Decision::PassThrough);
assert_eq!(render(&d), None, "pass-through emits nothing");
}
#[test]
fn orchestrator_no_agent_id_passes_through() {
let d = decide(
&bash(None, "rm -rf /"),
Path::new(WT),
false,
None,
&Backend::Bwrap,
&JailPolicy::default(),
);
assert_eq!(d, Decision::PassThrough);
assert_eq!(render(&d), None);
}
#[test]
fn subagent_outside_a_project_worktree_denies() {
let d = decide(
&bash(Some("a1"), "echo hi"),
Path::new("/home/u/proj"),
false,
None,
&Backend::Bwrap,
&JailPolicy::default(),
);
let v = parse(&render(&d).expect("deny emits JSON"));
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], DECISION_DENY);
let reason = v["hookSpecificOutput"]["permissionDecisionReason"]
.as_str()
.unwrap();
assert!(reason.contains("cwd-not-a-worktree"), "reason: {reason}");
}
#[test]
fn write_to_repo_root_ancestor_denies() {
let d = decide(
&input(Some("a1"), TOOL_EDIT),
Path::new(WT),
true,
Some(Path::new("/home/u/proj/Cargo.toml")),
&Backend::Bwrap,
&JailPolicy::default(),
);
let v = parse(&render(&d).expect("deny emits JSON"));
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], DECISION_DENY);
}
#[test]
fn unregistered_tool_passes_through() {
let d = decide(
&input(Some("a1"), "Read"),
Path::new(WT),
true,
None,
&Backend::Bwrap,
&JailPolicy::default(),
);
assert_eq!(d, Decision::PassThrough);
}
#[test]
fn jailed_bash_with_no_bwrap_backend_denies_not_passthrough() {
let d = decide(
&bash(Some("a1"), "echo hi"),
Path::new(WT),
true,
None,
&Backend::Deny {
reason: REASON_NO_BWRAP.to_string(),
},
&JailPolicy::default(),
);
let v = parse(&render(&d).expect("deny emits JSON"));
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], DECISION_DENY);
let reason = v["hookSpecificOutput"]["permissionDecisionReason"]
.as_str()
.unwrap();
assert!(reason.contains(REASON_NO_BWRAP), "per-arm reason: {reason}");
}
use std::os::unix::fs::symlink;
fn write_decision(wt: &Path, real: &Path) -> Decision {
let mut inp = input(Some("a1"), TOOL_WRITE);
inp.cwd = Some(wt.display().to_string());
decide(
&inp,
wt,
true,
Some(real),
&Backend::Bwrap,
&JailPolicy::default(),
)
}
#[test]
fn canonicalize_resolves_dotdot_escape_and_denies() {
let tmp = tempfile::tempdir().unwrap();
let root = fs::canonicalize(tmp.path()).unwrap();
let wt = root.join("wt");
let outside = root.join("outside");
fs::create_dir_all(&wt).unwrap();
fs::create_dir_all(&outside).unwrap();
let real = canonicalize_missing(&wt, "../outside/secret").expect("realpath -m");
assert!(
!real.starts_with(&wt),
"`..` must resolve OUTSIDE the worktree: {}",
real.display()
);
assert_eq!(
write_decision(&wt, &real),
Decision::Deny {
reason: format!("escapes-worktree: {}", real.display()),
},
"an escaping `..` write is denied"
);
}
#[test]
fn canonicalize_follows_symlink_escape_and_denies() {
let tmp = tempfile::tempdir().unwrap();
let root = fs::canonicalize(tmp.path()).unwrap();
let wt = root.join("wt");
let outside = root.join("outside");
fs::create_dir_all(&wt).unwrap();
fs::create_dir_all(&outside).unwrap();
symlink(&outside, wt.join("escape")).unwrap();
let real = canonicalize_missing(&wt, "escape/secret").expect("realpath -m");
assert!(
real.starts_with(&outside) && !real.starts_with(&wt.join("escape")),
"symlink resolved to the real target outside: {}",
real.display()
);
assert!(
matches!(write_decision(&wt, &real), Decision::Deny { .. }),
"a write through an escaping symlink is denied"
);
}
#[test]
fn canonicalize_keeps_in_worktree_write_and_passes() {
let tmp = tempfile::tempdir().unwrap();
let root = fs::canonicalize(tmp.path()).unwrap();
let wt = root.join("wt");
fs::create_dir_all(&wt).unwrap();
let real = canonicalize_missing(&wt, "src/new.rs").expect("realpath -m");
assert!(real.starts_with(&wt), "in-worktree: {}", real.display());
assert_eq!(
write_decision(&wt, &real),
Decision::PassThrough,
"an in-worktree write passes"
);
}
fn provision(root: &Path, name: &str, body: Option<&str>) -> PathBuf {
let wt = root.join(WORKTREES_SUBDIR).join(name);
fs::create_dir_all(&wt).unwrap();
if let Some(body) = body {
let jail = root.join(JAIL_SUBPATH);
fs::create_dir_all(&jail).unwrap();
fs::write(jail.join(format!("{name}.toml")), body).unwrap();
}
wt
}
#[test]
fn resolves_declared_policy_by_cwd_basename() {
let tmp = tempfile::tempdir().unwrap();
let root = fs::canonicalize(tmp.path()).unwrap();
let allowed = root.join("allowed");
fs::create_dir_all(&allowed).unwrap();
let wt = provision(
&root,
"agent-abc",
Some(&format!(
"network = false\nextra_rw = [\"{}\"]\n",
allowed.display()
)),
);
let policy = resolve_provisioned_policy(&wt);
assert!(!policy.network, "declared network=false honoured");
assert_eq!(policy.extra_rw, vec![allowed], "materialised extra_rw");
}
#[test]
fn absent_declaration_resolves_default_floor() {
let tmp = tempfile::tempdir().unwrap();
let root = fs::canonicalize(tmp.path()).unwrap();
let wt = provision(&root, "agent-abc", None); assert_eq!(resolve_provisioned_policy(&wt), JailPolicy::default());
}
#[test]
fn malformed_declaration_floors_never_denies() {
let tmp = tempfile::tempdir().unwrap();
let root = fs::canonicalize(tmp.path()).unwrap();
let wt = provision(&root, "agent-abc", Some("extra_rws = []\n"));
assert_eq!(resolve_provisioned_policy(&wt), JailPolicy::default());
}
#[test]
fn non_existent_extra_rw_floors_whole_policy() {
let tmp = tempfile::tempdir().unwrap();
let root = fs::canonicalize(tmp.path()).unwrap();
let wt = provision(
&root,
"agent-abc",
Some("extra_rw = [\"/does/not/exist\"]\n"),
);
assert_eq!(resolve_provisioned_policy(&wt), JailPolicy::default());
}
#[test]
fn unsafe_extra_rw_ancestor_floors() {
let tmp = tempfile::tempdir().unwrap();
let root = fs::canonicalize(tmp.path()).unwrap();
let wt = provision(
&root,
"agent-abc",
Some(&format!("extra_rw = [\"{}\"]\n", root.display())),
);
assert_eq!(resolve_provisioned_policy(&wt), JailPolicy::default());
}
#[test]
fn parallel_siblings_share_one_provisioned_profile() {
let tmp = tempfile::tempdir().unwrap();
let root = fs::canonicalize(tmp.path()).unwrap();
let body = "network = false\n";
let a = provision(&root, "agent-a", Some(body));
let b = provision(&root, "agent-b", Some(body));
let pa = resolve_provisioned_policy(&a);
let pb = resolve_provisioned_policy(&b);
assert_eq!(pa, pb, "siblings share one profile");
assert!(!pa.network, "the shared declared profile is honoured");
}
#[test]
fn cwd_outside_worktrees_layout_floors() {
let tmp = tempfile::tempdir().unwrap();
let root = fs::canonicalize(tmp.path()).unwrap();
let stray = root.join("not-worktrees").join("agent-abc");
fs::create_dir_all(&stray).unwrap();
assert_eq!(resolve_provisioned_policy(&stray), JailPolicy::default());
}
use crate::worktree::jail::ResolvedMac;
fn resolved_at(tmp_dir: &Path) -> ResolvedMac {
ResolvedMac {
wt: tmp_dir.parent().unwrap().to_path_buf(),
tmp: tmp_dir.to_path_buf(),
dutmp: PathBuf::from("/private/var/folders/x/T"),
extra_rw: vec![],
network: false,
profile_path: tmp_dir.join("jail.sb"),
}
}
#[test]
fn materialize_writes_profile_body_and_keeps_wrap() {
let tmp = tempfile::tempdir().unwrap();
let dot_tmp = fs::canonicalize(tmp.path()).unwrap().join(".tmp");
fs::create_dir_all(&dot_tmp).unwrap();
let resolved = resolved_at(&dot_tmp);
let backend = Backend::Seatbelt(resolved.clone());
let wrap = Decision::WrapBash {
command: "sandbox-exec -f jail.sb -- bash -c ...".to_string(),
description: "d".to_string(),
};
let out = materialize_seatbelt_profile(&backend, wrap.clone());
assert_eq!(out, wrap, "successful materialize leaves the wrap intact");
let body = fs::read_to_string(&resolved.profile_path).expect("profile written");
assert_eq!(
body,
seatbelt_profile(&resolved),
"materialized body == seatbelt_profile(resolved)"
);
}
#[test]
fn materialize_fail_closed_denies_when_profile_unwritable() {
let tmp = tempfile::tempdir().unwrap();
let root = fs::canonicalize(tmp.path()).unwrap();
let mut resolved = resolved_at(&root.join(".tmp"));
resolved.profile_path = root.join("nonexistent-dir").join("jail.sb");
let backend = Backend::Seatbelt(resolved);
let wrap = Decision::WrapBash {
command: "sandbox-exec ...".to_string(),
description: "d".to_string(),
};
let out = materialize_seatbelt_profile(&backend, wrap);
match out {
Decision::Deny { reason } => {
assert!(
reason.contains(REASON_PROFILE_WRITE_FAILED),
"fail-closed reason: {reason}"
);
}
other => panic!("expected fail-closed Deny, got {other:?}"),
}
}
#[test]
fn materialize_is_noop_for_non_seatbelt_backends() {
let wrap = Decision::WrapBash {
command: "bwrap ...".to_string(),
description: "d".to_string(),
};
assert_eq!(
materialize_seatbelt_profile(&Backend::Bwrap, wrap.clone()),
wrap,
"bwrap wrap is untouched"
);
let deny = Decision::Deny {
reason: "x".to_string(),
};
let backend = Backend::Seatbelt(ResolvedMac::default());
assert_eq!(
materialize_seatbelt_profile(&backend, deny.clone()),
deny,
"a Seatbelt deny needs no profile"
);
}
#[test]
fn write_seatbelt_profile_writes_exact_body_to_profile_path() {
let tmp = tempfile::tempdir().unwrap();
let dot_tmp = fs::canonicalize(tmp.path()).unwrap().join(".tmp");
fs::create_dir_all(&dot_tmp).unwrap();
let resolved = resolved_at(&dot_tmp);
write_seatbelt_profile(&resolved).expect("write succeeds");
let body = fs::read_to_string(&resolved.profile_path).expect("profile written");
assert_eq!(
body,
seatbelt_profile(&resolved),
"write_seatbelt_profile writes exactly seatbelt_profile(resolved)"
);
}
#[test]
fn write_seatbelt_profile_io_error_returns_err() {
let tmp = tempfile::tempdir().unwrap();
let root = fs::canonicalize(tmp.path()).unwrap();
let mut resolved = resolved_at(&root.join(".tmp"));
resolved.profile_path = root.join("nonexistent-dir").join("jail.sb");
let result = write_seatbelt_profile(&resolved);
assert!(
result.is_err(),
"write_seatbelt_profile must return Err on io error"
);
}
}