diff --git a/profiles/opencode.toml b/profiles/opencode.toml new file mode 100644 index 0000000..f088b9e --- /dev/null +++ b/profiles/opencode.toml @@ -0,0 +1,31 @@ +# OpenCode profile +# Provides access to OpenCode configuration and cache directories +network_mode = "online" + +[filesystem] +allow_read = [ + # Custom open code + "~/.local/share/opencode", + "~/.local/state/opencode", + "~/.config/opencode", + "~/.cache/opencode", + "/private/tmp/.*", +] + +allow_write = [ + # Open code + "~/.config/opencode", + "~/.local/share/opencode", + "~/.local/state/opencode", + "~/.cache/opencode", + "/private/tmp/.*", +] + +# Directory listing permissions +allow_list_dirs = [ + "/private/tmp" +] + +[shell] +# No additional environment variables needed +pass_env = [] \ No newline at end of file diff --git a/src/cli/args.rs b/src/cli/args.rs index db93b68..36a9a23 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -17,7 +17,8 @@ pub use crate::config::schema::NetworkMode; localhost Localhost network only\n \ rust Rust/Cargo toolchain\n \ claude Claude Code (~/.claude access)\n \ - gpg GPG signing support")] + gpg GPG signing support\n \ + opencode OpenCode (~/.config/opencode access)")] pub struct Args { /// Enable verbose output (show sandbox config) #[arg(short, long)] diff --git a/src/cli/commands.rs b/src/cli/commands.rs index da58e3c..739c4b0 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -195,14 +195,24 @@ fn load_effective_config(args: &Args, working_dir: &Path) -> Result { return Ok(Config::default()); } - // Load global config - let global = - load_global_config(args.config.as_deref()).context("Failed to load global config")?; + // Explicit -c flag: treat as project config + if let Some(config_path) = &args.config { + let content = std::fs::read_to_string(config_path) + .with_context(|| format!("Failed to read config: {}", config_path.display()))?; + let project: Config = toml::from_str(&content) + .with_context(|| format!("Failed to parse config: {}", config_path.display()))?; + + if project.sandbox.inherit_global { + let global = load_global_config(None).context("Failed to load global config")?; + return Ok(merge_configs(&global, &project)); + } + return Ok(project); + } - // Load project config + // Default: load global config and project config from working directory + let global = load_global_config(None).context("Failed to load global config")?; let project = load_project_config(working_dir).context("Failed to load project config")?; - // Merge if project config exists and inherits match project { Some(proj) if proj.sandbox.inherit_global => Ok(merge_configs(&global, &proj)), Some(proj) => Ok(proj), @@ -382,7 +392,7 @@ fn generate_config_template() -> &'static str { inherit_global = true # Profiles to apply for this project -# Available: base, online, localhost, rust, claude, gpg +# Available: base, online, localhost, rust, claude, gpg, opencode profiles = [] # Default network mode: "offline", "online", or "localhost" diff --git a/src/config/profile.rs b/src/config/profile.rs index 8dd4550..f6ffb07 100644 --- a/src/config/profile.rs +++ b/src/config/profile.rs @@ -102,6 +102,7 @@ pub enum BuiltinProfile { Claude, Gpg, Bun, + Opencode, } impl BuiltinProfile { @@ -115,6 +116,7 @@ impl BuiltinProfile { "claude" => Some(Self::Claude), "gpg" => Some(Self::Gpg), "bun" => Some(Self::Bun), + "opencode" => Some(Self::Opencode), _ => None, } } @@ -129,6 +131,7 @@ impl BuiltinProfile { Self::Claude => "claude", Self::Gpg => "gpg", Self::Bun => "bun", + Self::Opencode => "opencode", } } @@ -146,6 +149,7 @@ impl BuiltinProfile { Self::Claude => include_str!("../../profiles/claude.toml"), Self::Gpg => include_str!("../../profiles/gpg.toml"), Self::Bun => include_str!("../../profiles/bun.toml"), + Self::Opencode => include_str!("../../profiles/opencode.toml"), }; toml::from_str(toml_str).map_err(|e| ProfileError::InvalidBuiltin { name: self.name(), diff --git a/tests/config_test.rs b/tests/config_test.rs index 2052b90..205c575 100644 --- a/tests/config_test.rs +++ b/tests/config_test.rs @@ -280,3 +280,182 @@ fn test_merge_configs_shell_env() { Some(&"active".to_string()) ); } + +#[test] +fn test_inherit_global_true_merges_configs() { + let temp_dir = TempDir::new().unwrap(); + + // Write a global config + let global_path = temp_dir.path().join("global.toml"); + std::fs::write( + &global_path, + r#" +[filesystem] +allow_read = ["~/.gitconfig"] +"#, + ) + .unwrap(); + + // Write a project config with inherit_global = true (default) + let project_path = temp_dir.path().join(".sandbox.toml"); + std::fs::write( + &project_path, + r#" +[sandbox] +inherit_global = true + +[filesystem] +allow_read = ["~/.claude"] +"#, + ) + .unwrap(); + + let global = load_global_config(Some(&global_path)).unwrap(); + let project = load_project_config(temp_dir.path()).unwrap().unwrap(); + + // Simulate load_effective_config: inherit_global=true → merge + assert!(project.sandbox.inherit_global); + let effective = merge_configs(&global, &project); + + // Both global and project paths should be present + assert!(effective.filesystem.allow_read.contains(&"~/.gitconfig".to_string())); + assert!(effective.filesystem.allow_read.contains(&"~/.claude".to_string())); +} + +#[test] +fn test_inherit_global_false_skips_global() { + let temp_dir = TempDir::new().unwrap(); + + // Write a global config with extra paths + let global_path = temp_dir.path().join("global.toml"); + std::fs::write( + &global_path, + r#" +[sandbox] +default_network = "online" + +[filesystem] +allow_read = ["~/.gitconfig", "~/.cargo"] +"#, + ) + .unwrap(); + + // Write a project config that opts out of global inheritance + let project_path = temp_dir.path().join(".sandbox.toml"); + std::fs::write( + &project_path, + r#" +[sandbox] +inherit_global = false + +[filesystem] +allow_read = ["~/.claude"] +"#, + ) + .unwrap(); + + let global = load_global_config(Some(&global_path)).unwrap(); + let project = load_project_config(temp_dir.path()).unwrap().unwrap(); + + // Simulate load_effective_config: inherit_global=false → use project only + assert!(!project.sandbox.inherit_global); + let effective = if project.sandbox.inherit_global { + merge_configs(&global, &project) + } else { + project + }; + + // Only project paths, global paths must NOT be present + assert!(effective.filesystem.allow_read.contains(&"~/.claude".to_string())); + assert!(!effective.filesystem.allow_read.contains(&"~/.gitconfig".to_string())); + assert!(!effective.filesystem.allow_read.contains(&"~/.cargo".to_string())); + // Network stays at project default (offline), not inherited from global (online) + assert_eq!(effective.sandbox.default_network, NetworkMode::Offline); +} + +#[test] +fn test_custom_config_inherit_global_false_uses_standalone() { + // When -c specifies a config with inherit_global = false, + // it is used as-is without merging with the global config. + let temp_dir = TempDir::new().unwrap(); + + let global_path = temp_dir.path().join("global.toml"); + std::fs::write( + &global_path, + r#" +[filesystem] +allow_read = ["~/.gitconfig"] +"#, + ) + .unwrap(); + + let custom_path = temp_dir.path().join("custom.toml"); + std::fs::write( + &custom_path, + r#" +[sandbox] +inherit_global = false + +[filesystem] +allow_read = ["~/.custom"] +"#, + ) + .unwrap(); + + // Simulate load_effective_config with -c flag + let content = std::fs::read_to_string(&custom_path).unwrap(); + let project: Config = toml::from_str(&content).unwrap(); + + // inherit_global = false → use project config as-is + assert!(!project.sandbox.inherit_global); + assert!(project.filesystem.allow_read.contains(&"~/.custom".to_string())); + assert!(!project.filesystem.allow_read.contains(&"~/.gitconfig".to_string())); +} + +#[test] +fn test_custom_config_inherit_global_true_merges_with_global() { + // When -c specifies a config with inherit_global = true, + // it is merged with the global config from the default location. + let temp_dir = TempDir::new().unwrap(); + + let global_path = temp_dir.path().join("global.toml"); + std::fs::write( + &global_path, + r#" +[filesystem] +allow_read = ["~/.gitconfig", "~/.config/git/"] +allow_write = ["~/.cache/"] +"#, + ) + .unwrap(); + + let custom_path = temp_dir.path().join("custom.toml"); + std::fs::write( + &custom_path, + r#" +[sandbox] +inherit_global = true +profiles = ["online"] + +[filesystem] +allow_read = ["~/.custom"] +allow_write = ["~/.custom-data/"] +"#, + ) + .unwrap(); + + // Simulate load_effective_config with -c flag + inherit_global = true + let content = std::fs::read_to_string(&custom_path).unwrap(); + let project: Config = toml::from_str(&content).unwrap(); + let global = load_global_config(Some(&global_path)).unwrap(); + + assert!(project.sandbox.inherit_global); + let effective = merge_configs(&global, &project); + + // Both global and project paths should be merged + assert!(effective.filesystem.allow_read.contains(&"~/.gitconfig".to_string())); + assert!(effective.filesystem.allow_read.contains(&"~/.config/git/".to_string())); + assert!(effective.filesystem.allow_read.contains(&"~/.custom".to_string())); + assert!(effective.filesystem.allow_write.contains(&"~/.cache/".to_string())); + assert!(effective.filesystem.allow_write.contains(&"~/.custom-data/".to_string())); +} diff --git a/tests/profile_test.rs b/tests/profile_test.rs index 932a62e..e6d772a 100644 --- a/tests/profile_test.rs +++ b/tests/profile_test.rs @@ -212,5 +212,51 @@ fn test_builtin_profile_from_name() { Some(BuiltinProfile::Claude) ); assert_eq!(BuiltinProfile::from_name("gpg"), Some(BuiltinProfile::Gpg)); + assert_eq!( + BuiltinProfile::from_name("opencode"), + Some(BuiltinProfile::Opencode) + ); assert_eq!(BuiltinProfile::from_name("unknown"), None); } + +#[test] +fn test_builtin_profile_opencode() { + let profile = BuiltinProfile::Opencode.load().unwrap(); + assert_eq!(profile.network_mode, Some(NetworkMode::Online)); + assert!(profile + .filesystem + .allow_read + .contains(&"~/.local/share/opencode".to_string())); + assert!(profile + .filesystem + .allow_read + .contains(&"~/.local/state/opencode".to_string())); + assert!(profile + .filesystem + .allow_read + .contains(&"~/.config/opencode".to_string())); + assert!(profile + .filesystem + .allow_read + .contains(&"~/.cache/opencode".to_string())); + assert!(profile + .filesystem + .allow_list_dirs + .contains(&"/private/tmp".to_string())); + assert!(profile + .filesystem + .allow_write + .contains(&"~/.config/opencode".to_string())); + assert!(profile + .filesystem + .allow_write + .contains(&"~/.local/share/opencode".to_string())); + assert!(profile + .filesystem + .allow_write + .contains(&"~/.local/state/opencode".to_string())); + assert!(profile + .filesystem + .allow_write + .contains(&"~/.cache/opencode".to_string())); +}