Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions profiles/opencode.toml
Original file line number Diff line number Diff line change
@@ -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 = []
3 changes: 2 additions & 1 deletion src/cli/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
22 changes: 16 additions & 6 deletions src/cli/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,14 +195,24 @@ fn load_effective_config(args: &Args, working_dir: &Path) -> Result<Config> {
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),
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions src/config/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ pub enum BuiltinProfile {
Claude,
Gpg,
Bun,
Opencode,
}

impl BuiltinProfile {
Expand All @@ -115,6 +116,7 @@ impl BuiltinProfile {
"claude" => Some(Self::Claude),
"gpg" => Some(Self::Gpg),
"bun" => Some(Self::Bun),
"opencode" => Some(Self::Opencode),
_ => None,
}
}
Expand All @@ -129,6 +131,7 @@ impl BuiltinProfile {
Self::Claude => "claude",
Self::Gpg => "gpg",
Self::Bun => "bun",
Self::Opencode => "opencode",
}
}

Expand All @@ -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(),
Expand Down
179 changes: 179 additions & 0 deletions tests/config_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
}
46 changes: 46 additions & 0 deletions tests/profile_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
}
Loading