From 095721c1057ac9fc596ced99c4f5b8d66fc0e2e7 Mon Sep 17 00:00:00 2001 From: Pierre Tomasina Date: Tue, 17 Feb 2026 22:29:04 +0800 Subject: [PATCH 1/3] test(config): add inherit_global configuration tests - Test merging global and project configs when inherit_global=true - Test skipping global config when inherit_global=false - Test that inherit_global is ignored in custom config files --- tests/config_test.rs | 132 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/tests/config_test.rs b/tests/config_test.rs index 2052b90..bc9b864 100644 --- a/tests/config_test.rs +++ b/tests/config_test.rs @@ -280,3 +280,135 @@ 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_inherit_global_in_custom_config_file_is_ignored() { + // When -c is used, the custom file becomes the "global" config. + // inherit_global inside it has no effect since only the *project* + // config's inherit_global is checked. + let temp_dir = TempDir::new().unwrap(); + + // Custom config passed via -c, with inherit_global = false + let custom_path = temp_dir.path().join("custom.toml"); + std::fs::write( + &custom_path, + r#" +[sandbox] +inherit_global = false + +[filesystem] +allow_read = ["~/.custom"] +"#, + ) + .unwrap(); + + // No .sandbox.toml exists in the project dir + let global = load_global_config(Some(&custom_path)).unwrap(); + let project = load_project_config(temp_dir.path()).unwrap(); + + // Simulate load_effective_config logic + let effective = match project { + Some(proj) if proj.sandbox.inherit_global => merge_configs(&global, &proj), + Some(proj) => proj, + None => global, + }; + + // The custom config's inherit_global=false is ignored—it falls through + // to `None => global` because there's no .sandbox.toml. + // The flag only has meaning when set in a *project* config. + assert!(effective.filesystem.allow_read.contains(&"~/.custom".to_string())); + // inherit_global is still false in the loaded struct, but it was never checked + assert!(!effective.sandbox.inherit_global); +} From 17b8631b872fa797261bad70ec006739ab163009 Mon Sep 17 00:00:00 2001 From: Pierre Tomasina Date: Tue, 17 Feb 2026 23:45:17 +0800 Subject: [PATCH 2/3] fix(config): respect inherit_global when using -c flag When -c specifies a config file, treat it as a project config and check inherit_global to decide if the default global config should be merged. Previously, the -c file was always loaded as global config, making inherit_global ineffective. Scenarios now working correctly: - -c with inherit_global=true: merges with ~/.config/sx/config.toml - -c with inherit_global=false: uses config standalone - -c with inherit_base=false: full custom control over allowed paths - No -c flag: loads global config + project .sandbox.toml as before --- src/cli/commands.rs | 20 ++++++++--- tests/config_test.rs | 85 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 81 insertions(+), 24 deletions(-) diff --git a/src/cli/commands.rs b/src/cli/commands.rs index da58e3c..bed44f6 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), diff --git a/tests/config_test.rs b/tests/config_test.rs index bc9b864..205c575 100644 --- a/tests/config_test.rs +++ b/tests/config_test.rs @@ -374,13 +374,21 @@ allow_read = ["~/.claude"] } #[test] -fn test_inherit_global_in_custom_config_file_is_ignored() { - // When -c is used, the custom file becomes the "global" config. - // inherit_global inside it has no effect since only the *project* - // config's inherit_global is checked. +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(); - // Custom config passed via -c, with inherit_global = false + 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, @@ -394,21 +402,60 @@ allow_read = ["~/.custom"] ) .unwrap(); - // No .sandbox.toml exists in the project dir - let global = load_global_config(Some(&custom_path)).unwrap(); - let project = load_project_config(temp_dir.path()).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(); - // Simulate load_effective_config logic - let effective = match project { - Some(proj) if proj.sandbox.inherit_global => merge_configs(&global, &proj), - Some(proj) => proj, - None => global, - }; + // 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(); - // The custom config's inherit_global=false is ignored—it falls through - // to `None => global` because there's no .sandbox.toml. - // The flag only has meaning when set in a *project* config. + // 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())); - // inherit_global is still false in the loaded struct, but it was never checked - assert!(!effective.sandbox.inherit_global); + assert!(effective.filesystem.allow_write.contains(&"~/.cache/".to_string())); + assert!(effective.filesystem.allow_write.contains(&"~/.custom-data/".to_string())); } From ab7ef5d2943174065485f3377ab510a6740d8ed9 Mon Sep 17 00:00:00 2001 From: Pierre Tomasina Date: Wed, 18 Feb 2026 00:52:09 +0800 Subject: [PATCH 3/3] feat(profile): add opencode profile Adds built-in opencode profile with access to opencode config, cache, and state directories. Includes updates to CLI help text, config template documentation, and comprehensive tests. --- profiles/opencode.toml | 31 ++++++++++++++++++++++++++++ src/cli/args.rs | 3 ++- src/cli/commands.rs | 2 +- src/config/profile.rs | 4 ++++ tests/profile_test.rs | 46 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 profiles/opencode.toml 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 bed44f6..739c4b0 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -392,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/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())); +}