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
32 changes: 16 additions & 16 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"

[workspace.package]
version = "0.1.159"
version = "0.1.160"
edition = "2024"
rust-version = "1.88"
license = "Apache-2.0"
Expand Down
32 changes: 29 additions & 3 deletions crates/cli-sub-agent/src/pipeline_sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ pub(crate) fn resolve_sandbox_options(
}

// Build IsolationPlan via builder (BestEffort for profile defaults).
let plan = IsolationPlanBuilder::new(ResourceEnforcementMode::BestEffort)
let cwd = std::env::current_dir().unwrap_or_default();
let mut builder = IsolationPlanBuilder::new(ResourceEnforcementMode::BestEffort)
.with_resource_capability(resource_cap)
.with_filesystem_capability(fs_cap)
.with_resource_limits(
Expand All @@ -102,11 +103,23 @@ pub(crate) fn resolve_sandbox_options(
.with_tool_defaults(
tool_name,
// No project root available from profile defaults; use cwd.
&std::env::current_dir().unwrap_or_default(),
&cwd,
// No session dir available; use a temporary placeholder.
&std::env::temp_dir(),
)
.with_readonly_project_root(readonly_project_root)
.with_readonly_project_root(readonly_project_root);

// CSA runtime writable paths (best-effort for profile defaults).
if !no_fs_sandbox {
if let Ok(project_state_root) = csa_session::manager::get_session_root(&cwd) {
builder = builder.with_writable_path(project_state_root);
}
if let Ok(slots) = csa_config::GlobalConfig::slots_dir() {
builder = builder.with_writable_path(slots);
}
}

let plan = builder
.build()
.expect("BestEffort IsolationPlan should never fail");

Expand Down Expand Up @@ -223,6 +236,19 @@ pub(crate) fn resolve_sandbox_options(
.with_tool_defaults(tool_name, &project_root, &session_dir)
.with_readonly_project_root(effective_readonly);

// CSA runtime writable paths: project state root (for fork-call session
// creation) and global slots (for lock files). These are always added
// regardless of per-tool REPLACE semantics because CSA needs them to
// function even when the tool's project-root access is restricted.
if !no_fs_sandbox {
if let Ok(project_state_root) = csa_session::manager::get_session_root(&project_root) {
builder = builder.with_writable_path(project_state_root);
}
if let Ok(slots) = csa_config::GlobalConfig::slots_dir() {
builder = builder.with_writable_path(slots);
}
}

if !no_fs_sandbox {
if let Some(ref paths) = per_tool_writable {
// Validate user-provided writable paths before applying.
Expand Down
118 changes: 118 additions & 0 deletions crates/cli-sub-agent/src/pipeline_sandbox_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,3 +281,121 @@ enforcement_mode = "best-effort"
"Tool-level FS enforcement should override global 'off'"
);
}

/// Verify that resolve_sandbox_options injects CSA state paths (project state
/// root and global slots) into the isolation plan's writable_paths.
#[test]
fn test_csa_state_paths_in_writable_paths() {
let cfg = parse_project_config(
r#"
[resources]
memory_max_mb = 2048
enforcement_mode = "best-effort"
"#,
);

let result = resolve_sandbox_options(
Some(&cfg),
"claude-code",
"test-session",
StreamMode::BufferOnly,
120,
600,
Some(120),
false, // no_fs_sandbox
false, // readonly_project_root
);

let SandboxResolution::Ok(opts) = result else {
panic!("Expected SandboxResolution::Ok");
};

let Some(ref sandbox) = opts.sandbox else {
// No sandbox capability on this host — skip assertions.
return;
};

let writable = &sandbox.isolation_plan.writable_paths;

// Project state root should be present (allows fork-call session creation).
let cwd = std::env::current_dir().unwrap_or_default();
if let Ok(project_state_root) = csa_session::manager::get_session_root(&cwd) {
assert!(
writable.contains(&project_state_root),
"writable_paths should include project state root: {project_state_root:?}\n actual: {writable:?}"
);
}

// Global slots directory should be present (allows lock file creation).
if let Ok(slots) = csa_config::GlobalConfig::slots_dir() {
assert!(
writable.contains(&slots),
"writable_paths should include slots dir: {slots:?}\n actual: {writable:?}"
);
}
}

/// Verify that CSA state paths are present even when per-tool REPLACE
/// semantics restrict project-root writability.
#[test]
fn test_csa_state_paths_survive_replace_semantics() {
let cfg = parse_project_config(
r#"
[resources]
memory_max_mb = 2048
enforcement_mode = "best-effort"

[tools.claude-code.filesystem_sandbox]
writable_paths = ["/tmp/restricted-only"]
"#,
);

let result = resolve_sandbox_options(
Some(&cfg),
"claude-code",
"test-session",
StreamMode::BufferOnly,
120,
600,
Some(120),
false,
false,
);

let SandboxResolution::Ok(opts) = result else {
panic!("Expected SandboxResolution::Ok");
};

let Some(ref sandbox) = opts.sandbox else {
return;
};

let writable = &sandbox.isolation_plan.writable_paths;

// Project root should NOT be writable (REPLACE semantics make it readonly).
assert!(
sandbox.isolation_plan.readonly_project_root,
"REPLACE semantics should set readonly_project_root"
);

// But CSA state paths should still be present.
let cwd = std::env::current_dir().unwrap_or_default();
if let Ok(project_state_root) = csa_session::manager::get_session_root(&cwd) {
assert!(
writable.contains(&project_state_root),
"CSA state root must survive REPLACE semantics"
);
}
if let Ok(slots) = csa_config::GlobalConfig::slots_dir() {
assert!(
writable.contains(&slots),
"slots dir must survive REPLACE semantics"
);
}

// Per-tool restricted path should also be present.
assert!(
writable.contains(&PathBuf::from("/tmp/restricted-only")),
"per-tool writable path should be present"
);
}
52 changes: 52 additions & 0 deletions crates/csa-resource/src/isolation_plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ impl IsolationPlanBuilder {
self.writable_paths.push(home.join(".codex"));
}
"gemini-cli" => {
// OAuth tokens, session history, project settings
self.writable_paths.push(home.join(".gemini"));
Comment on lines 195 to +197
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid binding ~/.gemini before the directory exists

On Linux hosts that use bubblewrap, this new writable path will break sandboxed gemini-cli launches whenever ~/.gemini has not been created yet (for example, first-time users or setups that only have the XDG config dir). with_tool_defaults() now always appends ~/.gemini, and csa-resource/src/bwrap.rs turns every writable path into --bind src src without checking that src exists, so the child fails before Gemini can create the directory itself. Binding an existing parent or creating the directory up front avoids that regression.

Useful? React with 👍 / 👎.

// XDG config dir (settings.json, etc.)
self.writable_paths.push(home.join(".config/gemini-cli"));
}
"opencode" => {
Expand Down Expand Up @@ -524,6 +527,55 @@ mod tests {
}
}

#[test]
fn test_tool_defaults_gemini_cli() {
let project = PathBuf::from("/tmp/project");
let session = PathBuf::from("/tmp/session");

let plan = IsolationPlanBuilder::new(EnforcementMode::BestEffort)
.with_filesystem_capability(FilesystemCapability::Bwrap)
.with_tool_defaults("gemini-cli", &project, &session)
.build()
.expect("should succeed");

assert!(plan.writable_paths.contains(&project));
assert!(plan.writable_paths.contains(&session));

if let Some(home) = home_dir() {
assert!(
plan.writable_paths.contains(&home.join(".gemini")),
"gemini-cli defaults should include ~/.gemini"
);
assert!(
plan.writable_paths
.contains(&home.join(".config/gemini-cli")),
"gemini-cli defaults should include ~/.config/gemini-cli"
);
}
}

#[test]
fn test_tool_defaults_opencode() {
let project = PathBuf::from("/tmp/project");
let session = PathBuf::from("/tmp/session");

let plan = IsolationPlanBuilder::new(EnforcementMode::BestEffort)
.with_filesystem_capability(FilesystemCapability::Bwrap)
.with_tool_defaults("opencode", &project, &session)
.build()
.expect("should succeed");

assert!(plan.writable_paths.contains(&project));
assert!(plan.writable_paths.contains(&session));

if let Some(home) = home_dir() {
assert!(
plan.writable_paths.contains(&home.join(".config/opencode")),
"opencode defaults should include ~/.config/opencode"
);
}
}

// -----------------------------------------------------------------------
// validate_writable_paths tests
// -----------------------------------------------------------------------
Expand Down
Loading
Loading