diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 5aa63a3..5090d0e 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -60,6 +60,13 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ argument_hint: None, resume_supported: true, }, + SlashCommandSpec { + name: "sandbox", + aliases: &[], + summary: "Show sandbox isolation status", + argument_hint: None, + resume_supported: true, + }, SlashCommandSpec { name: "compact", aliases: &[], @@ -229,6 +236,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ pub enum SlashCommand { Help, Status, + Sandbox, Compact, Bughunter { scope: Option, @@ -300,6 +308,7 @@ impl SlashCommand { Some(match command { "help" => Self::Help, "status" => Self::Status, + "sandbox" => Self::Sandbox, "compact" => Self::Compact, "bughunter" => Self::Bughunter { scope: remainder_after_command(trimmed, command), @@ -1188,6 +1197,7 @@ pub fn handle_slash_command( | SlashCommand::Ultraplan { .. } | SlashCommand::Teleport { .. } | SlashCommand::DebugToolCall + | SlashCommand::Sandbox | SlashCommand::Model { .. } | SlashCommand::Permissions { .. } | SlashCommand::Clear { .. } @@ -1287,6 +1297,7 @@ mod tests { fn parses_supported_slash_commands() { assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help)); assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status)); + assert_eq!(SlashCommand::parse("/sandbox"), Some(SlashCommand::Sandbox)); assert_eq!( SlashCommand::parse("/bughunter runtime"), Some(SlashCommand::Bughunter { @@ -1416,6 +1427,7 @@ mod tests { assert!(help.contains("works with --resume SESSION.json")); assert!(help.contains("/help")); assert!(help.contains("/status")); + assert!(help.contains("/sandbox")); assert!(help.contains("/compact")); assert!(help.contains("/bughunter [scope]")); assert!(help.contains("/commit")); @@ -1436,14 +1448,15 @@ mod tests { assert!(help.contains("/version")); assert!(help.contains("/export [file]")); assert!(help.contains("/session [list|switch ]")); + assert!(help.contains("/sandbox")); assert!(help.contains( "/plugin [list|install |enable |disable |uninstall |update ]" )); assert!(help.contains("aliases: /plugins, /marketplace")); assert!(help.contains("/agents")); assert!(help.contains("/skills")); - assert_eq!(slash_command_specs().len(), 25); - assert_eq!(resume_supported_slash_commands().len(), 13); + assert_eq!(slash_command_specs().len(), 27); + assert_eq!(resume_supported_slash_commands().len(), 14); } #[test] @@ -1490,6 +1503,7 @@ mod tests { let session = Session::new(); assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none()); assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none()); + assert!(handle_slash_command("/sandbox", &session, CompactionConfig::default()).is_none()); assert!( handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none() ); diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index 9dcb239..03d4191 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -76,6 +76,12 @@ pub use remote::{ RemoteSessionContext, UpstreamProxyBootstrap, UpstreamProxyState, DEFAULT_REMOTE_BASE_URL, DEFAULT_SESSION_TOKEN_PATH, DEFAULT_SYSTEM_CA_BUNDLE, NO_PROXY_HOSTS, UPSTREAM_PROXY_ENV_KEYS, }; +pub use sandbox::{ + build_linux_sandbox_command, detect_container_environment, detect_container_environment_from, + resolve_sandbox_status, resolve_sandbox_status_for_request, ContainerEnvironment, + FilesystemIsolationMode, LinuxSandboxCommand, SandboxConfig, SandboxDetectionInputs, + SandboxRequest, SandboxStatus, +}; pub use session::{ContentBlock, ConversationMessage, MessageRole, Session, SessionError}; pub use usage::{ format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker, diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 66ed9e7..c588eba 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -34,6 +34,10 @@ use runtime::{ parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock, ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, OAuthConfig, + parse_oauth_callback_request_target, resolve_sandbox_status, save_oauth_credentials, + ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, + ContentBlock, ConversationMessage, ConversationRuntime, MessageRole, + OAuthAuthorizationRequest, OAuthConfig, OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, }; @@ -656,6 +660,7 @@ struct StatusContext { memory_file_count: usize, project_root: Option, git_branch: Option, + sandbox_status: runtime::SandboxStatus, } #[derive(Debug, Clone, Copy)] @@ -928,6 +933,18 @@ fn run_resume_command( )), }) } + SlashCommand::Sandbox => { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load()?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format_sandbox_report(&resolve_sandbox_status( + runtime_config.sandbox(), + &cwd, + ))), + }) + } SlashCommand::Cost => { let usage = UsageTracker::from_session(session).cumulative_usage(); Ok(ResumeCommandOutcome { @@ -1237,6 +1254,10 @@ impl LiveCli { self.run_debug_tool_call()?; false } + SlashCommand::Sandbox => { + Self::print_sandbox_status(); + false + } SlashCommand::Compact => { self.compact()?; false @@ -1319,6 +1340,18 @@ impl LiveCli { ); } + fn print_sandbox_status() { + let cwd = env::current_dir().expect("current dir"); + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader + .load() + .unwrap_or_else(|_| runtime::RuntimeConfig::empty()); + println!( + "{}", + format_sandbox_report(&resolve_sandbox_status(runtime_config.sandbox(), &cwd)) + ); + } + fn set_model(&mut self, model: Option) -> Result> { let Some(model) = model else { println!( @@ -1922,6 +1955,7 @@ fn status_context( let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?; let (project_root, git_branch) = parse_git_status_metadata(project_context.git_status.as_deref()); + let sandbox_status = resolve_sandbox_status(runtime_config.sandbox(), &cwd); Ok(StatusContext { cwd, session_path: session_path.map(Path::to_path_buf), @@ -1930,6 +1964,7 @@ fn status_context( memory_file_count: project_context.instruction_files.len(), project_root, git_branch, + sandbox_status, }) } @@ -1982,6 +2017,7 @@ fn format_status_report( context.discovered_config_files, context.memory_file_count, ), + format_sandbox_report(&context.sandbox_status), ] .join( " @@ -1990,6 +2026,49 @@ fn format_status_report( ) } +fn format_sandbox_report(status: &runtime::SandboxStatus) -> String { + format!( + "Sandbox + Enabled {} + Active {} + Supported {} + In container {} + Requested ns {} + Active ns {} + Requested net {} + Active net {} + Filesystem mode {} + Filesystem active {} + Allowed mounts {} + Markers {} + Fallback reason {}", + status.enabled, + status.active, + status.supported, + status.in_container, + status.requested.namespace_restrictions, + status.namespace_active, + status.requested.network_isolation, + status.network_active, + status.filesystem_mode.as_str(), + status.filesystem_active, + if status.allowed_mounts.is_empty() { + "".to_string() + } else { + status.allowed_mounts.join(", ") + }, + if status.container_markers.is_empty() { + "".to_string() + } else { + status.container_markers.join(", ") + }, + status + .fallback_reason + .clone() + .unwrap_or_else(|| "".to_string()), + ) +} + fn render_config_report(section: Option<&str>) -> Result> { let cwd = env::current_dir()?; let loader = ConfigLoader::default_for(&cwd); @@ -4249,6 +4328,7 @@ mod tests { assert!(help.contains("REPL")); assert!(help.contains("/help")); assert!(help.contains("/status")); + assert!(help.contains("/sandbox")); assert!(help.contains("/model [model]")); assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); assert!(help.contains("/clear [--confirm]")); @@ -4279,8 +4359,8 @@ mod tests { assert_eq!( names, vec![ - "help", "status", "compact", "clear", "cost", "config", "memory", "init", "diff", - "version", "export", "agents", "skills", + "help", "status", "sandbox", "compact", "clear", "cost", "config", "memory", + "init", "diff", "version", "export", "agents", "skills", ] ); } @@ -4400,6 +4480,7 @@ mod tests { memory_file_count: 4, project_root: Some(PathBuf::from("/tmp")), git_branch: Some("main".to_string()), + sandbox_status: runtime::SandboxStatus::default(), }, ); assert!(status.contains("Status")); @@ -4996,3 +5077,17 @@ mod tests { assert!(!String::from_utf8(out).expect("utf8").contains("step 1")); } } + +#[cfg(test)] +mod sandbox_report_tests { + use super::format_sandbox_report; + + #[test] + fn sandbox_report_renders_expected_fields() { + let report = format_sandbox_report(&runtime::SandboxStatus::default()); + assert!(report.contains("Sandbox")); + assert!(report.contains("Enabled")); + assert!(report.contains("Filesystem mode")); + assert!(report.contains("Fallback reason")); + } +} diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index c22db4c..3e970cd 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -225,7 +225,11 @@ pub fn mvp_tool_specs() -> Vec { "timeout": { "type": "integer", "minimum": 1 }, "description": { "type": "string" }, "run_in_background": { "type": "boolean" }, - "dangerouslyDisableSandbox": { "type": "boolean" } + "dangerouslyDisableSandbox": { "type": "boolean" }, + "namespaceRestrictions": { "type": "boolean" }, + "isolateNetwork": { "type": "boolean" }, + "filesystemMode": { "type": "string", "enum": ["off", "workspace-only", "allow-list"] }, + "allowedMounts": { "type": "array", "items": { "type": "string" } } }, "required": ["command"], "additionalProperties": false