use crate::cli::SetupArgs;
use crate::profile;
use nono::{NonoError, Result};
use std::fs;
use std::path::Path;
#[cfg(target_os = "macos")]
use nix::libc;
pub struct SetupRunner {
check_only: bool,
generate_profiles: bool,
show_shell_integration: bool,
#[allow(dead_code)]
verbose: u8,
}
impl SetupRunner {
pub fn new(args: &SetupArgs) -> Self {
Self {
check_only: args.check_only,
generate_profiles: args.profiles,
show_shell_integration: args.shell_integration,
verbose: args.verbose,
}
}
pub fn run(&self) -> Result<()> {
self.check_installation()?;
self.test_sandbox_support()?;
self.show_protection_summary()?;
self.show_builtin_profiles();
if !self.check_only {
if self.generate_profiles {
self.setup_profiles()?;
}
if self.show_shell_integration {
self.show_shell_help();
}
}
self.show_summary();
Ok(())
}
fn check_installation(&self) -> Result<()> {
println!("[1/{}] Checking installation...", self.total_phases());
let exe_path = std::env::current_exe()
.map_err(|e| NonoError::Setup(format!("Failed to get executable path: {}", e)))?;
println!(" * nono binary found at {}", exe_path.display());
println!(" * Version: {}", env!("CARGO_PKG_VERSION"));
let platform = if cfg!(target_os = "macos") {
"macOS (Seatbelt sandbox)"
} else if cfg!(target_os = "linux") {
"Linux (Landlock sandbox)"
} else if cfg!(target_os = "windows") {
return Err(NonoError::Setup(
"Windows is not supported. nono requires macOS (Seatbelt) or Linux (Landlock) for sandboxing.".to_string()
));
} else {
return Err(NonoError::Setup(
"Unsupported platform. nono requires macOS (Seatbelt) or Linux (Landlock)."
.to_string(),
));
};
println!(" * Platform: {}", platform);
println!();
Ok(())
}
fn test_sandbox_support(&self) -> Result<()> {
println!("[2/{}] Testing sandbox support...", self.total_phases());
#[cfg(target_os = "macos")]
self.test_macos_seatbelt()?;
#[cfg(target_os = "linux")]
self.test_linux_landlock()?;
println!();
Ok(())
}
#[cfg(target_os = "macos")]
fn test_macos_seatbelt(&self) -> Result<()> {
use std::ffi::CString;
use std::ptr;
let version_output = std::process::Command::new("sw_vers")
.arg("-productVersion")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string());
if let Some(version) = version_output {
println!(" * macOS version: {}", version);
}
let test_profile = CString::new("(version 1)\n(allow default)\n")
.map_err(|e| NonoError::Setup(format!("Failed to create test profile: {}", e)))?;
unsafe {
let pid = libc::fork();
if pid == 0 {
extern "C" {
fn sandbox_init(
profile: *const std::os::raw::c_char,
flags: u64,
errorbuf: *mut *mut std::os::raw::c_char,
) -> i32;
}
let result = sandbox_init(test_profile.as_ptr(), 0, ptr::null_mut());
std::process::exit(if result == 0 { 0 } else { 1 });
} else if pid > 0 {
let mut status = 0;
libc::waitpid(pid, &mut status, 0);
if libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0 {
println!(" * Seatbelt sandbox support verified");
println!(" * File access restrictions: OK");
println!(" * Network restrictions: OK");
} else {
return Err(NonoError::Setup(
"Seatbelt sandbox test failed. This may indicate a system configuration issue.".to_string()
));
}
} else {
return Err(NonoError::Setup("Failed to fork test process".to_string()));
}
}
Ok(())
}
#[cfg(target_os = "linux")]
fn test_linux_landlock(&self) -> Result<()> {
let kernel_version = std::fs::read_to_string("/proc/version").ok().and_then(|s| {
s.split_whitespace()
.nth(2)
.map(|v| v.trim_end_matches('-').to_string())
});
if let Some(version) = kernel_version {
println!(" * Kernel version: {}", version);
}
let detected = nono::Sandbox::detect_abi()
.map_err(|e| NonoError::Setup(format!(
"Landlock is not available: {}\n\n\
To enable Landlock:\n\
1. Check your kernel config: CONFIG_SECURITY_LANDLOCK=y\n\
2. Add to boot params: lsm=landlock,lockdown,yama,integrity,apparmor\n\
3. Reboot your system\n\n\
See: https://github.com/always-further/nono/docs/troubleshooting.md#landlock-not-supported",
e
)))?;
println!(" * Landlock enabled (syscall probe)");
println!(" * {}", detected);
println!(" * Available features:");
for feature in detected.feature_names() {
println!(" - {}", feature);
}
println!(" * Filesystem ruleset creation verified");
if nono::sandbox::is_wsl2() {
println!(" * WSL2 environment detected");
println!(
" - Filesystem sandbox: available (Landlock {})",
detected.version_string()
);
println!(" - Block-all network (--block-net): available");
if detected.has_network() {
println!(" - Per-port network filtering: available (Landlock V4+)");
} else {
println!(" - Per-port network filtering: unavailable (needs kernel 6.7+ for Landlock V4)");
}
println!(
" - Credential proxy (--credential): requires wsl2_proxy_policy profile opt-in"
);
println!(" - Capability elevation (--capability-elevation): unavailable");
println!(" Note: seccomp user notification returns EBUSY (microsoft/WSL#9548)");
}
if detected.has_network() {
if verify_landlock_network_rule_support(detected.abi)? {
println!(" * TCP network rule support verified");
} else {
println!(" * TCP network filtering: probe failed despite ABI support");
}
} else {
match nono::sandbox::probe_seccomp_block_network_support()? {
true => println!(
" * TCP network filtering: not supported by this ABI \
(seccomp fallback available: full --block-net and --proxy-only modes)"
),
false => println!(
" * TCP network filtering: not supported by this ABI \
(seccomp fallback is not available on this system)"
),
}
}
Ok(())
}
fn show_protection_summary(&self) -> Result<()> {
println!("[3/{}] Default protections...", self.total_phases());
let loaded_policy = crate::policy::load_embedded_policy()?;
let sensitive_paths = crate::policy::get_sensitive_paths(&loaded_policy)?;
let dangerous_commands = crate::policy::get_dangerous_commands(&loaded_policy);
println!(
" * {} sensitive paths blocked by default:",
sensitive_paths.len()
);
println!(" SSH keys, AWS/GCP/Azure credentials, Kubernetes config,");
println!(" Docker config, GPG keys, password managers, shell configs");
println!(
" * {} dangerous commands blocked by default:",
dangerous_commands.len()
);
let mut all_commands: Vec<_> = dangerous_commands.iter().cloned().collect();
all_commands.sort();
let sample: Vec<_> = all_commands.into_iter().take(8).collect();
println!(" {}, ...", sample.join(", "));
println!(" * Network access: allowed by default (use --block-net to disable)");
println!();
Ok(())
}
fn show_builtin_profiles(&self) {
println!("[4/{}] Built-in profiles...", self.total_phases());
let profiles = profile::list_profiles();
for name in &profiles {
match profile::load_profile(name) {
Ok(p) => {
let desc = p
.meta
.description
.unwrap_or_else(|| "No description".to_string());
let net_status = if p.network.block {
"network blocked"
} else {
"network allowed"
};
println!(" * {} - {} ({})", name, desc, net_status);
}
Err(e) => {
println!(" * {} - <warning: failed to load: {}>", name, e);
}
}
}
println!();
println!(" Use with: nono run --profile <name> -- <command>");
println!();
}
fn setup_profiles(&self) -> Result<()> {
println!("[5/{}] Setting up example profiles...", self.total_phases());
let profile_dir = crate::profile::resolve_user_config_dir()?
.join("nono")
.join("profiles");
fs::create_dir_all(&profile_dir).map_err(|e| {
NonoError::Setup(format!(
"Failed to create profile directory {}: {}",
profile_dir.display(),
e
))
})?;
println!(" * Created directory: {}", profile_dir.display());
self.write_example_profile(&profile_dir, "example-agent.json", EXAMPLE_AGENT_PROFILE)?;
self.write_example_profile(&profile_dir, "offline-build.json", OFFLINE_BUILD_PROFILE)?;
self.write_example_profile(
&profile_dir,
"data-processing.json",
DATA_PROCESSING_PROFILE,
)?;
println!();
Ok(())
}
fn write_example_profile(&self, dir: &Path, filename: &str, content: &str) -> Result<()> {
let path = dir.join(filename);
fs::write(&path, content)
.map_err(|e| NonoError::Setup(format!("Failed to write {}: {}", filename, e)))?;
println!(" * Generated {}", filename);
Ok(())
}
fn show_shell_help(&self) {
println!("[6/{}] Shell integration...", self.total_phases());
let shell = std::env::var("SHELL")
.ok()
.and_then(|s| s.split('/').next_back().map(String::from))
.unwrap_or_else(|| "bash".to_string());
let shell_rc = match shell.as_str() {
"zsh" => "~/.zshrc",
"bash" => "~/.bashrc",
"fish" => "~/.config/fish/config.fish",
_ => "~/.bashrc",
};
println!(" You can add these aliases to {}:", shell_rc);
println!();
println!(" alias nono-claude='nono run --profile claude-code -- claude'");
println!(" alias nono-safe='nono run --allow-cwd --block-net --'");
println!();
}
fn show_summary(&self) {
println!("-----------------------------------------------------------");
println!();
if self.check_only {
println!("Installation verified!");
println!();
println!("Your system is ready to use nono. Run 'nono run --help' to get started.");
} else {
println!("Setup complete!");
println!();
println!("Quick start examples:");
println!();
println!(" # Run Claude Code with built-in profile (recommended)");
println!(" nono run --profile claude-code -- claude");
println!();
println!(" # Run any command with current directory access");
println!(" nono run --allow-cwd -- <command>");
println!();
println!(" # Check why a sensitive path is blocked");
println!(" nono why ~/.ssh/id_rsa");
println!();
if self.generate_profiles {
println!("Custom profiles:");
let profile_dir = crate::profile::resolve_user_config_dir()
.map(|p| p.join("nono").join("profiles"))
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "~/.config/nono/profiles".to_string());
println!(" Edit example profiles in: {}", profile_dir);
println!();
}
println!("Documentation: https://github.com/always-further/nono#readme");
println!();
println!("Run 'nono run --help' to see all options.");
}
}
fn total_phases(&self) -> usize {
let mut count = 4;
if !self.check_only {
if self.generate_profiles {
count += 1;
}
if self.show_shell_integration {
count += 1;
}
}
count
}
}
#[cfg(target_os = "linux")]
fn verify_landlock_network_rule_support(abi: landlock::ABI) -> Result<bool> {
use landlock::{
Access, AccessNet, CompatLevel, Compatible, NetPort, Ruleset, RulesetAttr,
RulesetCreatedAttr,
};
let handled_net = AccessNet::from_all(abi);
if handled_net.is_empty() {
return Ok(false);
}
let ruleset = Ruleset::default()
.set_compatibility(CompatLevel::HardRequirement)
.handle_access(handled_net)
.map_err(|e| NonoError::Setup(format!("Failed to probe Landlock network access: {}", e)))?
.create()
.map_err(|e| NonoError::Setup(format!("Failed to create network probe ruleset: {}", e)))?;
ruleset
.set_compatibility(CompatLevel::HardRequirement)
.add_rule(NetPort::new(443, AccessNet::ConnectTcp))
.map_err(|e| NonoError::Setup(format!("Failed to add TCP connect probe rule: {}", e)))?
.add_rule(NetPort::new(444, AccessNet::BindTcp))
.map_err(|e| NonoError::Setup(format!("Failed to add TCP bind probe rule: {}", e)))?;
Ok(true)
}
const EXAMPLE_AGENT_PROFILE: &str = r#"{
"meta": {
"name": "example-agent",
"version": "1.0.0",
"description": "Template for creating custom agent profiles"
},
"filesystem": {
"allow": ["$WORKDIR"],
"read": ["$HOME/.config/my-agent"],
"write": []
},
"network": {
"block": false
}
}
"#;
const OFFLINE_BUILD_PROFILE: &str = r#"{
"meta": {
"name": "offline-build",
"version": "1.0.0",
"description": "Build environment with no network access"
},
"filesystem": {
"allow": ["$WORKDIR"],
"read": ["$HOME/.cargo", "$HOME/.rustup"]
},
"network": {
"block": true
}
}
"#;
const DATA_PROCESSING_PROFILE: &str = r#"{
"meta": {
"name": "data-processing",
"version": "1.0.0",
"description": "Read from input, write to output"
},
"filesystem": {
"read": ["$WORKDIR/input"],
"write": ["$WORKDIR/output"],
"read_file": ["$WORKDIR/config.yaml"]
},
"network": {
"block": false
}
}
"#;
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_setup_profiles_loadable_by_name() {
let _guard = match crate::test_env::ENV_LOCK.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
let tmp = tempdir().expect("tempdir");
let _env = crate::test_env::EnvVarGuard::set_all(&[
("HOME", tmp.path().to_str().expect("tmp path")),
("XDG_CONFIG_HOME", "__placeholder__"),
]);
_env.remove("XDG_CONFIG_HOME");
let runner = SetupRunner {
check_only: false,
generate_profiles: true,
show_shell_integration: false,
verbose: 0,
};
runner.setup_profiles().expect("setup_profiles failed");
let profile = crate::profile::load_profile("example-agent")
.expect("example-agent profile written by setup was not found by load_profile()");
assert_eq!(profile.meta.name, "example-agent");
}
#[cfg(target_os = "linux")]
#[test]
fn test_library_detect_abi_returns_result() {
let _ = nono::Sandbox::detect_abi();
}
#[cfg(target_os = "linux")]
#[test]
fn test_detected_abi_has_network_for_v4_plus() {
let detected = nono::DetectedAbi::new(landlock::ABI::V4);
assert!(detected.has_network());
assert!(detected
.feature_names()
.iter()
.any(|n| n.starts_with("TCP network filtering")));
}
}