From db080eb6d82e37b68f4b420e58129d5a8be9fa5f Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Sat, 18 Jul 2026 20:05:45 +0800 Subject: [PATCH 1/8] feat: define custom model config at config.toml --- .../contributors/architecture-overview.mdx | 7 +- .../contributors/architecture-overview.zh.mdx | 2 +- .../docs/contributors/cli-and-tui-flow.mdx | 4 +- .../docs/contributors/cli-and-tui-flow.zh.mdx | 4 +- crates/cli/src/agent_command.rs | 14 +- crates/cli/src/prompt_command.rs | 2 +- crates/config/README.md | 93 ++- crates/config/src/app.rs | 8 +- crates/config/src/app_store_tests.rs | 2 +- crates/config/src/provider.rs | 56 ++ crates/config/src/provider/persistence.rs | 56 +- .../src/provider/runtime_equivalence.rs | 51 ++ crates/config/src/provider/schema.rs | 315 +++++++- crates/config/src/tests.rs | 82 ++ crates/core/src/model_catalog.rs | 742 ++++++------------ crates/core/src/model_catalog/user_sync.rs | 638 --------------- crates/core/src/model_preset.rs | 70 ++ crates/server/src/bootstrap.rs | 3 +- crates/server/src/execution.rs | 114 ++- crates/server/src/session_context.rs | 7 +- .../server/tests/acp_session_contract_e2e.rs | 41 +- crates/server/tests/cli_log_failures.rs | 41 +- crates/server/tests/model_config_e2e.rs | 41 +- crates/server/tests/persistence_resume.rs | 41 +- crates/tui/src/app.rs | 2 - crates/tui/src/interactive.rs | 5 - crates/tui/src/model_display_tests.rs | 1 - docs/configuration.ja.md | 129 ++- docs/configuration.md | 146 ++-- docs/configuration.ru.md | 131 +++- docs/configuration.zh-Hans.md | 122 ++- docs/configuration.zh-Hant.md | 122 ++- .../app/L2-DES-APP-005-config-toml-schema.md | 32 +- ...L2-DES-MODEL-001-model-provider-binding.md | 10 +- 34 files changed, 1559 insertions(+), 1575 deletions(-) create mode 100644 crates/config/src/provider/runtime_equivalence.rs delete mode 100644 crates/core/src/model_catalog/user_sync.rs diff --git a/apps/web/content/docs/contributors/architecture-overview.mdx b/apps/web/content/docs/contributors/architecture-overview.mdx index 7bf277e2..6cea1712 100644 --- a/apps/web/content/docs/contributors/architecture-overview.mdx +++ b/apps/web/content/docs/contributors/architecture-overview.mdx @@ -67,9 +67,10 @@ When a user runs `devo`: 1. `crates/cli/src/main.rs` parses the command. 2. The default command calls `run_agent` in `crates/cli/src/agent_command.rs`. -3. `run_agent` resolves the current working directory, `DEVO_HOME`, effective - config, model catalog, permission preset, provider settings, and saved model - entries. +3. `run_agent` resolves the current working directory and `DEVO_HOME`, loads the + workspace-effective app config, then constructs the model catalog from its + model metadata overrides before resolving the permission preset, provider + settings, and saved model entries. 4. `run_agent` calls `devo_tui::run_interactive_tui`. 5. The TUI starts with an `InitialTuiSession` containing the current session id, model, model binding id, provider wire API, reasoning effort selection, permission diff --git a/apps/web/content/docs/contributors/architecture-overview.zh.mdx b/apps/web/content/docs/contributors/architecture-overview.zh.mdx index 5df2e273..ba112c2c 100644 --- a/apps/web/content/docs/contributors/architecture-overview.zh.mdx +++ b/apps/web/content/docs/contributors/architecture-overview.zh.mdx @@ -62,7 +62,7 @@ flowchart TD 1. `crates/cli/src/main.rs` 解析命令。 2. 默认命令调用 `crates/cli/src/agent_command.rs` 中的 `run_agent`。 -3. `run_agent` 解析当前工作目录、`DEVO_HOME`、effective config、model catalog、permission preset、provider settings 和 saved model entries。 +3. `run_agent` 解析当前工作目录和 `DEVO_HOME`,先加载工作区 effective app config,再用其中的模型元数据覆盖构建 model catalog,之后解析 permission preset、provider settings 和 saved model entries。 4. `run_agent` 调用 `devo_tui::run_interactive_tui`。 5. TUI 以 `InitialTuiSession` 启动,其中包含当前 session id、model、model binding id、provider wire API、reasoning effort selection、permission preset 和 working directory。 diff --git a/apps/web/content/docs/contributors/cli-and-tui-flow.mdx b/apps/web/content/docs/contributors/cli-and-tui-flow.mdx index 6833f4b9..77d93ac1 100644 --- a/apps/web/content/docs/contributors/cli-and-tui-flow.mdx +++ b/apps/web/content/docs/contributors/cli-and-tui-flow.mdx @@ -33,8 +33,8 @@ The path: 1. Resolve current working directory. 2. Resolve `DEVO_HOME`. -3. Load the preset model catalog. -4. Load effective app config for the current workspace. +3. Load effective app config for the current workspace. +4. Construct the preset model catalog from the effective config's model metadata overrides. 5. Resolve project permission preset. 6. Decide whether onboarding should be shown. 7. Resolve provider settings or use an onboarding fallback model. diff --git a/apps/web/content/docs/contributors/cli-and-tui-flow.zh.mdx b/apps/web/content/docs/contributors/cli-and-tui-flow.zh.mdx index bef8d539..c163a84d 100644 --- a/apps/web/content/docs/contributors/cli-and-tui-flow.zh.mdx +++ b/apps/web/content/docs/contributors/cli-and-tui-flow.zh.mdx @@ -31,8 +31,8 @@ Interactive startup 实现在 `crates/cli/src/agent_command.rs`。 1. 解析当前工作目录。 2. 解析 `DEVO_HOME`。 -3. 加载 preset model catalog。 -4. 加载当前工作区的 effective app config。 +3. 加载当前工作区的 effective app config。 +4. 使用 effective config 中的模型元数据覆盖构建 preset model catalog。 5. 解析 project permission preset。 6. 决定是否显示 onboarding。 7. 解析 provider settings,或使用 onboarding fallback model。 diff --git a/crates/cli/src/agent_command.rs b/crates/cli/src/agent_command.rs index 108254f9..a629b3a4 100644 --- a/crates/cli/src/agent_command.rs +++ b/crates/cli/src/agent_command.rs @@ -33,19 +33,8 @@ pub(crate) async fn run_agent( ) -> Result { let cwd = std::env::current_dir()?; let config_home = find_devo_home().context("could not determine devo home directory")?; - let model_catalog = PresetModelCatalog::load_from_config(&config_home, Some(&cwd))?; - let startup_warnings = model_catalog - .warnings() - .iter() - .map(|warning| { - format!( - "Model catalog warning for {}: {}", - warning.path.display(), - warning.message - ) - }) - .collect(); let app_config = FileSystemAppConfigLoader::new(config_home.clone()).load(Some(&cwd))?; + let model_catalog = PresetModelCatalog::load_from_config(&app_config.provider.model_overrides)?; let project_key = project_config_key(&cwd); let permission_preset = initial_permission_preset(&app_config, &project_key, dangerously_skip_permissions); @@ -108,7 +97,6 @@ pub(crate) async fn run_agent( saved_models, show_model_onboarding: onboarding_mode, exit_after_onboarding, - startup_warnings, }) .await?; tracing::info!("interactive tui returned to cli agent command"); diff --git a/crates/cli/src/prompt_command.rs b/crates/cli/src/prompt_command.rs index 6f5d53d3..6b0e6499 100644 --- a/crates/cli/src/prompt_command.rs +++ b/crates/cli/src/prompt_command.rs @@ -55,7 +55,7 @@ pub(crate) async fn run_prompt( .unwrap_or_else(|_| AppConfig::default()); let resolved_provider = devo_server::load_server_provider(&app_config, model_override, &home_dir)?; - let model_catalog = PresetModelCatalog::load_from_config(&home_dir, Some(&cwd))?; + let model_catalog = PresetModelCatalog::load_from_config(&app_config.provider.model_overrides)?; let turn_config = prompt_turn_config( &app_config, &model_catalog, diff --git a/crates/config/README.md b/crates/config/README.md index 95e5ffd6..bc00b8ba 100644 --- a/crates/config/README.md +++ b/crates/config/README.md @@ -453,21 +453,84 @@ The resolved runtime settings contain the provider id, wire API, final model name, optional base URL, optional API key, model limits, reasoning effort selection, response-storage flag, and preferred auth method. -`model_slug` is the local catalog key matching a `slug` in the effective -`models.json` catalog. `request_model` is the provider-specific model name used for -the API request. The legacy `model_name` key is accepted when reading existing -configuration, while subsequent writes use `request_model`. The effective catalog precedence is -`/.devo/models.json`, then `/models.json`, then built-in -defaults, merged by `slug`. Turn metadata records `model` as the catalog slug -and `request_model` as the provider request model; these values may be -identical. - -Untouched built-in entries in `/models.json` are synchronized with -the catalog bundled in the running binary. Devo records the source fingerprint -and update policy in a reserved `_devo` object. Editing model fields preserves -the full entry as a user override. Set `_devo.update_policy` to `"pinned"` to -prevent an unchanged built-in entry from being refreshed explicitly. Custom -slugs and workspace-level catalogs are never rewritten by this synchronization. +`model_slug` is the local catalog key. Model metadata starts from the built-in +catalog and is overlaid field-by-field from user and workspace `config.toml` +`[model.]` sections. Existing slugs are partial overrides; new slugs create +custom models with safe defaults. `request_model` is the provider-facing model id +used for the API request. The legacy `model_name` key is accepted when reading +existing configuration, while subsequent writes use `request_model`. Turn +metadata records `model` as the catalog slug and `request_model` as the provider +request model; these values may be identical. + +Model metadata `provider` describes the wire API and accepts +`openai_chat_completions`, `openai_responses`, or `anthropic_messages`. The +binding's `invocation_method` is the operational connection choice and should +match that metadata. A usable custom model therefore needs a +`[providers.]` connection and `[model_bindings.]` binding; the provider's +optional `credential` points to an API key stored in `auth.json`. + +A built-in partial override can be as small as: + +```toml +[model.qwen3-coder-next] +context_window = 262144 +effective_context_window_percent = 90 +``` + +A custom model must also be selected through connection wiring: + +```toml +[defaults] +model_binding = "custom-example" + +[model.custom] +display_name = "Custom" +provider = "openai_responses" +context_window = 128000 +reasoning_capability = { levels = ["low", "medium", "high"] } +reasoning_implementation = "request_parameter" +default_reasoning_effort = "medium" + +[providers.example] +enabled = true +name = "Example" +base_url = "https://api.example.com/v1" +credential = "example_api_key" +wire_apis = ["openai_responses"] + +[model_bindings.custom-example] +enabled = true +model_slug = "custom" +provider = "example" +request_model = "provider-facing-model-id" +invocation_method = "openai_responses" +``` + +`ModelOverrideConfig` exposes `display_name`, `description`, `channel`, +`context_window`, `effective_context_window_percent`, `max_tokens`, `temperature`, +`top_p`, `top_k`, `provider`, `reasoning_capability`, +`reasoning_implementation`, `default_reasoning_effort`, `base_instructions`, +`input_modalities`, `truncation_policy`, and `supports_image_detail_original`. +`display_name` is the picker label, `description` is its explanatory text, and +`channel` groups related models. The effective context is +`context_window * effective_context_window_percent / 100` and is also the +automatic-compaction boundary; `max_tokens` is the default response-output +limit. `temperature`, `top_p`, and `top_k` are request sampling defaults. +`reasoning_capability` defines the choices shown to users, while +`reasoning_implementation` says whether a choice is disabled, sent as a request +parameter, or mapped to a configured wire-model variant; +`default_reasoning_effort` selects the initial effort. `input_modalities` +declares text/image input support, `truncation_policy` selects a byte- or +token-based request-content limit, and `supports_image_detail_original` +enables original-resolution image detail. Omitted built-in fields are +preserved. Omitted custom-model `base_instructions` use the default +instructions, while an explicit empty string means no base instructions. + +Old `/models.json` and `/.devo/models.json` files are +ignored. Migration is manual: copy desired fields into the corresponding user or +workspace `config.toml` `[model.]` sections. The legacy top-level scalar +`model = "slug"` remains readable, but it collides with the new `model` table +namespace, so new configuration must select with `[defaults].model_binding`. When reasoning effort resolution selects a model variant catalog slug, the provider request model is resolved from enabled bindings for the same provider as the diff --git a/crates/config/src/app.rs b/crates/config/src/app.rs index 837df3ea..9f358f93 100644 --- a/crates/config/src/app.rs +++ b/crates/config/src/app.rs @@ -43,7 +43,7 @@ use crate::write_atomic; use crate::write_provider_config; /// Stores the fully normalized runtime configuration. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AppConfig { /// The policy that selects which model should generate context summaries. pub summary_model: SummaryModelSelection, @@ -303,10 +303,6 @@ impl AppConfigStore { if !config.model_bindings.contains_key(&binding_id) { anyhow::bail!("default model binding `{binding_id}` does not exist"); } - if let Some(binding) = config.model_bindings.get(&binding_id) { - config.model_provider = Some(binding.provider.clone()); - config.model = Some(binding.model_slug.clone()); - } config.defaults.model_binding = Some(binding_id); } @@ -350,8 +346,6 @@ impl AppConfigStore { anyhow::bail!("model binding `{value}` is disabled"); } config.defaults.model_binding = Some(value.to_string()); - config.model_provider = Some(binding.provider.clone()); - config.model = Some(binding.model_slug.clone()); } "thought_level" => { config.model_reasoning_effort_selection = Some(value.to_string()); diff --git a/crates/config/src/app_store_tests.rs b/crates/config/src/app_store_tests.rs index 62102103..d02c2d44 100644 --- a/crates/config/src/app_store_tests.rs +++ b/crates/config/src/app_store_tests.rs @@ -66,7 +66,7 @@ enabled = true Some("alt-binding") ); assert_eq!(document["model_provider"].as_str(), Some("openai")); - assert_eq!(document["model"].as_str(), Some("alt-model")); + assert_eq!(document["model"].as_str(), Some("test-model")); assert_eq!( store .effective_config() diff --git a/crates/config/src/provider.rs b/crates/config/src/provider.rs index 043ddee2..2b92ac95 100644 --- a/crates/config/src/provider.rs +++ b/crates/config/src/provider.rs @@ -1,6 +1,7 @@ mod auth; mod persistence; mod resolve; +mod runtime_equivalence; mod schema; pub use devo_protocol::ProviderWireApi; @@ -41,6 +42,7 @@ mod tests { use super::AuthCredentialConfig; use super::AuthCredentialKind; use super::ModelBindingConfig; + use super::ModelOverrideConfig; use super::ModelProviderConfig; use super::PreferredAuthMethod; use super::ProviderConfigSection; @@ -49,6 +51,7 @@ mod tests { use super::ResolvedModelBinding; use super::ResolvedProviderSettings; use super::UserAuthConfigFile; + use super::read_provider_config; use super::read_user_auth_config; use super::resolve_enabled_model_binding; use super::resolve_model_binding; @@ -445,6 +448,59 @@ name = "Other" ); } + #[test] + fn write_provider_config_preserves_model_override_tables() { + let dir = tempfile::tempdir().expect("temp dir"); + let config_file = dir.path().join(super::CONFIG_FILE_NAME); + std::fs::write( + &config_file, + r#" +[model.grok-4] +display_name = "Grok 4" +context_window = 256000 +custom_model_key = "keep-me" +"#, + ) + .expect("write initial config"); + + let mut config = read_provider_config(&config_file).expect("read provider config"); + config.providers.insert( + "xai".to_string(), + ModelProviderConfig { + name: "xAI".to_string(), + wire_apis: vec![ProviderWireApi::OpenAIResponses], + ..ModelProviderConfig::default() + }, + ); + + write_provider_config(&config_file, &config).expect("write provider config"); + + let written = std::fs::read_to_string(&config_file).expect("read written config"); + let document: toml::Value = toml::from_str(&written).expect("parse written config"); + assert_eq!( + config.model_overrides, + [( + "grok-4".to_string(), + ModelOverrideConfig { + display_name: Some("Grok 4".to_string()), + context_window: Some(256_000), + ..ModelOverrideConfig::default() + }, + )] + .into_iter() + .collect() + ); + assert_eq!(document["model"].as_str(), None); + assert_eq!( + document["model"]["grok-4"]["display_name"].as_str(), + Some("Grok 4") + ); + assert_eq!( + document["model"]["grok-4"]["custom_model_key"].as_str(), + Some("keep-me") + ); + } + #[test] fn write_provider_config_removes_cleared_known_fields() { let dir = tempfile::tempdir().expect("temp dir"); diff --git a/crates/config/src/provider/persistence.rs b/crates/config/src/provider/persistence.rs index 38efe7db..44289a07 100644 --- a/crates/config/src/provider/persistence.rs +++ b/crates/config/src/provider/persistence.rs @@ -86,7 +86,61 @@ fn update_provider_config_document( .expect("provider config must serialize to a TOML table"); overlay_optional_key(document, replacement, "model_provider"); - overlay_optional_key(document, replacement, "model"); + if config.model_overrides.is_empty() { + overlay_optional_key(document, replacement, "model"); + } else { + let model_overrides = document + .entry("model".to_string()) + .or_insert_with(|| Value::Table(Default::default())); + let model_overrides = ensure_table(model_overrides); + let replacement_overrides = replacement.get("model").and_then(Value::as_table); + + for model_slug in config.model_overrides.keys() { + let model_override = model_overrides + .entry(model_slug.clone()) + .or_insert_with(|| Value::Table(Default::default())); + let model_override = ensure_table(model_override); + let replacement_override = replacement_overrides + .and_then(|overrides| overrides.get(model_slug)) + .and_then(Value::as_table); + + if let Some(replacement_override) = replacement_override { + overlay_optional_key(model_override, replacement_override, "display_name"); + overlay_optional_key(model_override, replacement_override, "description"); + overlay_optional_key(model_override, replacement_override, "context_window"); + overlay_optional_key( + model_override, + replacement_override, + "effective_context_window_percent", + ); + overlay_optional_key(model_override, replacement_override, "max_tokens"); + overlay_optional_key(model_override, replacement_override, "temperature"); + overlay_optional_key(model_override, replacement_override, "top_p"); + overlay_optional_key(model_override, replacement_override, "top_k"); + overlay_optional_key(model_override, replacement_override, "provider"); + overlay_optional_key(model_override, replacement_override, "reasoning_capability"); + overlay_optional_key( + model_override, + replacement_override, + "reasoning_implementation", + ); + overlay_optional_key( + model_override, + replacement_override, + "default_reasoning_effort", + ); + overlay_optional_key(model_override, replacement_override, "base_instructions"); + overlay_optional_key(model_override, replacement_override, "input_modalities"); + overlay_optional_key(model_override, replacement_override, "channel"); + overlay_optional_key(model_override, replacement_override, "truncation_policy"); + overlay_optional_key( + model_override, + replacement_override, + "supports_image_detail_original", + ); + } + } + } overlay_optional_key(document, replacement, "model_reasoning_effort_selection"); document.remove("model_thinking_selection"); document.remove("model_thinking"); diff --git a/crates/config/src/provider/runtime_equivalence.rs b/crates/config/src/provider/runtime_equivalence.rs new file mode 100644 index 00000000..283d1647 --- /dev/null +++ b/crates/config/src/provider/runtime_equivalence.rs @@ -0,0 +1,51 @@ +use super::ProviderConfigSection; + +impl ProviderConfigSection { + /// Returns whether both sections have identical provider runtime settings. + /// + /// This compares every field that historically participated in full section + /// equality except `model_overrides`. Those overrides shape catalog metadata + /// only, so changing them does not require rebuilding a provider or router. + pub fn is_operationally_equivalent_to(&self, other: &Self) -> bool { + let Self { + defaults: left_defaults, + model_provider: left_model_provider, + model: left_model, + model_reasoning_effort_selection: left_reasoning_effort, + model_auto_compact_token_limit: left_auto_compact_token_limit, + model_context_window: left_context_window, + disable_response_storage: left_disable_response_storage, + preferred_auth_method: left_preferred_auth_method, + providers: left_providers, + model_bindings: left_model_bindings, + model_overrides: _, + model_providers: left_model_providers, + } = self; + let Self { + defaults: right_defaults, + model_provider: right_model_provider, + model: right_model, + model_reasoning_effort_selection: right_reasoning_effort, + model_auto_compact_token_limit: right_auto_compact_token_limit, + model_context_window: right_context_window, + disable_response_storage: right_disable_response_storage, + preferred_auth_method: right_preferred_auth_method, + providers: right_providers, + model_bindings: right_model_bindings, + model_overrides: _, + model_providers: right_model_providers, + } = other; + + left_defaults == right_defaults + && left_model_provider == right_model_provider + && left_model == right_model + && left_reasoning_effort == right_reasoning_effort + && left_auto_compact_token_limit == right_auto_compact_token_limit + && left_context_window == right_context_window + && left_disable_response_storage == right_disable_response_storage + && left_preferred_auth_method == right_preferred_auth_method + && left_providers == right_providers + && left_model_bindings == right_model_bindings + && left_model_providers == right_model_providers + } +} diff --git a/crates/config/src/provider/schema.rs b/crates/config/src/provider/schema.rs index 3f4eda7c..32be0398 100644 --- a/crates/config/src/provider/schema.rs +++ b/crates/config/src/provider/schema.rs @@ -1,6 +1,11 @@ use std::collections::BTreeMap; +use devo_protocol::InputModality; use devo_protocol::ProviderWireApi; +use devo_protocol::ReasoningCapability; +use devo_protocol::ReasoningEffort; +use devo_protocol::ReasoningImplementation; +use devo_protocol::TruncationPolicyConfig; use serde::Deserialize; use serde::Serialize; @@ -137,6 +142,48 @@ impl Default for ModelBindingConfig { } } +/// Partial metadata overrides for one catalog model stored under `[model.]`. +/// +/// Each field is optional so user, workspace, and command-line config layers can +/// override independent model attributes without replacing lower-priority values. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct ModelOverrideConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub context_window: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub effective_context_window_percent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_p: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_k: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_capability: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_implementation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_reasoning_effort: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub base_instructions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub input_modalities: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub channel: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub truncation_policy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub supports_image_detail_original: Option, +} + /// Durable default selections stored under `[defaults]`. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ProviderDefaultsConfig { @@ -197,13 +244,10 @@ pub enum AuthCredentialKind { } /// Provider-owned portion of app config, including active model selection. -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq)] pub struct ProviderConfigSection { - #[serde(default, skip_serializing_if = "ProviderDefaultsConfig::is_empty")] pub defaults: ProviderDefaultsConfig, - #[serde(skip_serializing_if = "Option::is_none")] pub model_provider: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, /// Logical reasoning effort selection for the active model, such as `disabled`, /// `enabled`, or one effort-like level supported by the selected model. @@ -212,21 +256,14 @@ pub struct ProviderConfigSection { /// field. The runtime later resolves it into the final request model, /// provider `thinking` parameter, effective reasoning effort, and any /// provider-specific extra payload. - #[serde(skip_serializing_if = "Option::is_none")] pub model_reasoning_effort_selection: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub model_auto_compact_token_limit: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub model_context_window: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub disable_response_storage: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub preferred_auth_method: Option, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub providers: BTreeMap, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub model_bindings: BTreeMap, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub model_overrides: BTreeMap, pub model_providers: BTreeMap, } @@ -235,7 +272,7 @@ struct ProviderConfigSectionWire { #[serde(default)] defaults: ProviderDefaultsConfig, model_provider: Option, - model: Option, + model: Option, model_reasoning_effort_selection: Option, model_thinking_selection: Option, model_thinking: Option, @@ -251,12 +288,86 @@ struct ProviderConfigSectionWire { model_providers: BTreeMap, } +#[derive(Deserialize)] +#[serde(untagged)] +enum ModelConfigField { + Legacy(String), + Overrides(BTreeMap), +} + +#[derive(Serialize)] +#[serde(untagged)] +enum SerializedModelConfigField<'a> { + Legacy(&'a str), + Overrides(&'a BTreeMap), +} + +#[derive(Serialize)] +struct ProviderConfigSectionSerialize<'a> { + #[serde(default, skip_serializing_if = "ProviderDefaultsConfig::is_empty")] + defaults: &'a ProviderDefaultsConfig, + #[serde(skip_serializing_if = "Option::is_none")] + model_provider: &'a Option, + #[serde(skip_serializing_if = "Option::is_none")] + model: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + model_reasoning_effort_selection: &'a Option, + #[serde(skip_serializing_if = "Option::is_none")] + model_auto_compact_token_limit: &'a Option, + #[serde(skip_serializing_if = "Option::is_none")] + model_context_window: &'a Option, + #[serde(skip_serializing_if = "Option::is_none")] + disable_response_storage: &'a Option, + #[serde(skip_serializing_if = "Option::is_none")] + preferred_auth_method: &'a Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + providers: &'a BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + model_bindings: &'a BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + model_providers: &'a BTreeMap, +} + +impl Serialize for ProviderConfigSection { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let model = if self.model_overrides.is_empty() { + self.model + .as_deref() + .map(SerializedModelConfigField::Legacy) + } else { + Some(SerializedModelConfigField::Overrides(&self.model_overrides)) + }; + ProviderConfigSectionSerialize { + defaults: &self.defaults, + model_provider: &self.model_provider, + model, + model_reasoning_effort_selection: &self.model_reasoning_effort_selection, + model_auto_compact_token_limit: &self.model_auto_compact_token_limit, + model_context_window: &self.model_context_window, + disable_response_storage: &self.disable_response_storage, + preferred_auth_method: &self.preferred_auth_method, + providers: &self.providers, + model_bindings: &self.model_bindings, + model_providers: &self.model_providers, + } + .serialize(serializer) + } +} + impl From for ProviderConfigSection { fn from(wire: ProviderConfigSectionWire) -> Self { + let (model, model_overrides) = match wire.model { + Some(ModelConfigField::Legacy(model)) => (Some(model), BTreeMap::new()), + Some(ModelConfigField::Overrides(model_overrides)) => (None, model_overrides), + None => (None, BTreeMap::new()), + }; Self { defaults: wire.defaults, model_provider: wire.model_provider, - model: wire.model, + model, model_reasoning_effort_selection: wire .model_reasoning_effort_selection .or(wire.model_thinking_selection) @@ -267,6 +378,7 @@ impl From for ProviderConfigSection { preferred_auth_method: wire.preferred_auth_method, providers: wire.providers, model_bindings: wire.model_bindings, + model_overrides, model_providers: wire.model_providers, } } @@ -370,6 +482,62 @@ impl ProviderConfigSection { binding.enabled = overlay_binding.enabled; } } + for (model_slug, overlay_override) in overlay.model_overrides { + let model_override = self.model_overrides.entry(model_slug).or_default(); + if overlay_override.display_name.is_some() { + model_override.display_name = overlay_override.display_name; + } + if overlay_override.description.is_some() { + model_override.description = overlay_override.description; + } + if overlay_override.context_window.is_some() { + model_override.context_window = overlay_override.context_window; + } + if overlay_override.effective_context_window_percent.is_some() { + model_override.effective_context_window_percent = + overlay_override.effective_context_window_percent; + } + if overlay_override.max_tokens.is_some() { + model_override.max_tokens = overlay_override.max_tokens; + } + if overlay_override.temperature.is_some() { + model_override.temperature = overlay_override.temperature; + } + if overlay_override.top_p.is_some() { + model_override.top_p = overlay_override.top_p; + } + if overlay_override.top_k.is_some() { + model_override.top_k = overlay_override.top_k; + } + if overlay_override.provider.is_some() { + model_override.provider = overlay_override.provider; + } + if overlay_override.reasoning_capability.is_some() { + model_override.reasoning_capability = overlay_override.reasoning_capability; + } + if overlay_override.reasoning_implementation.is_some() { + model_override.reasoning_implementation = overlay_override.reasoning_implementation; + } + if overlay_override.default_reasoning_effort.is_some() { + model_override.default_reasoning_effort = overlay_override.default_reasoning_effort; + } + if overlay_override.base_instructions.is_some() { + model_override.base_instructions = overlay_override.base_instructions; + } + if overlay_override.input_modalities.is_some() { + model_override.input_modalities = overlay_override.input_modalities; + } + if overlay_override.channel.is_some() { + model_override.channel = overlay_override.channel; + } + if overlay_override.truncation_policy.is_some() { + model_override.truncation_policy = overlay_override.truncation_policy; + } + if overlay_override.supports_image_detail_original.is_some() { + model_override.supports_image_detail_original = + overlay_override.supports_image_detail_original; + } + } } } @@ -502,4 +670,123 @@ model_reasoning_effort_selection = "high" assert!(!serialized.contains("model_thinking_selection")); assert!(!serialized.contains("model_thinking")); } + + #[test] + fn provider_config_reads_legacy_model_selector() { + let config: ProviderConfigSection = + toml::from_str("model = \"legacy-model\"").expect("parse legacy model selector"); + + assert_eq!(config.model.as_deref(), Some("legacy-model")); + assert!(config.model_overrides.is_empty()); + assert_eq!( + toml::to_string(&config).expect("serialize legacy model selector"), + "model = \"legacy-model\"\n" + ); + } + + #[test] + fn provider_config_reads_model_override_table() { + let config: ProviderConfigSection = toml::from_str( + r#" +[model.grok-4] +display_name = "Grok 4" +description = "Fast reasoning model" +context_window = 256000 +effective_context_window_percent = 90 +max_tokens = 8192 +temperature = 0.7 +top_p = 0.95 +top_k = 40.0 +provider = "openai_responses" +reasoning_capability = "toggle" +reasoning_implementation = "request_parameter" +default_reasoning_effort = "high" +base_instructions = "Be concise." +input_modalities = ["text", "image"] +channel = "xAI" +truncation_policy = { mode = "tokens", limit = 12000 } +supports_image_detail_original = true +"#, + ) + .expect("parse model overrides"); + + assert_eq!(config.model, None); + assert_eq!( + config.model_overrides, + BTreeMap::from([( + "grok-4".to_string(), + ModelOverrideConfig { + display_name: Some("Grok 4".to_string()), + description: Some("Fast reasoning model".to_string()), + context_window: Some(256_000), + effective_context_window_percent: Some(90), + max_tokens: Some(8_192), + temperature: Some(0.7), + top_p: Some(0.95), + top_k: Some(40.0), + provider: Some(ProviderWireApi::OpenAIResponses), + reasoning_capability: Some(ReasoningCapability::Toggle), + reasoning_implementation: Some(ReasoningImplementation::RequestParameter), + default_reasoning_effort: Some(ReasoningEffort::High), + base_instructions: Some("Be concise.".to_string()), + input_modalities: Some(vec![InputModality::Text, InputModality::Image]), + channel: Some("xAI".to_string()), + truncation_policy: Some(TruncationPolicyConfig::tokens(12_000)), + supports_image_detail_original: Some(true), + }, + )]) + ); + + let serialized = toml::to_string(&config).expect("serialize model overrides"); + assert!(serialized.contains("[model.grok-4]")); + assert!(!serialized.contains("model = \"")); + } + + #[test] + fn provider_config_operational_equality_ignores_model_overrides() { + let baseline = ProviderConfigSection::default(); + let metadata_override = ProviderConfigSection { + model_overrides: BTreeMap::from([( + "custom-model".to_string(), + ModelOverrideConfig { + display_name: Some("Custom Model".to_string()), + ..ModelOverrideConfig::default() + }, + )]), + ..baseline.clone() + }; + + assert_ne!(baseline, metadata_override); + assert!(baseline.is_operationally_equivalent_to(&metadata_override)); + } + + #[test] + fn provider_config_operational_equality_detects_provider_and_binding_changes() { + let baseline = ProviderConfigSection::default(); + let provider_change = ProviderConfigSection { + providers: BTreeMap::from([( + "openai".to_string(), + ProviderVendorConfig { + name: "OpenAI".to_string(), + ..ProviderVendorConfig::default() + }, + )]), + ..baseline.clone() + }; + let binding_change = ProviderConfigSection { + model_bindings: BTreeMap::from([( + "main".to_string(), + ModelBindingConfig { + model_slug: "gpt-5.5".to_string(), + provider: "openai".to_string(), + request_model: "gpt-5.5".to_string(), + ..ModelBindingConfig::default() + }, + )]), + ..baseline.clone() + }; + + assert!(!baseline.is_operationally_equivalent_to(&provider_change)); + assert!(!baseline.is_operationally_equivalent_to(&binding_change)); + } } diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs index 33b10e4a..00cf7213 100644 --- a/crates/config/src/tests.rs +++ b/crates/config/src/tests.rs @@ -20,6 +20,7 @@ use super::HooksConfig; use super::LogRotation; use super::LoggingConfig; use super::ModelBindingConfig; +use super::ModelOverrideConfig; use super::OAuthCredentialsStoreMode; use super::ProjectConfig; use super::ProviderConfigSection; @@ -34,6 +35,8 @@ use crate::SkillsConfig; use devo_protocol::ProviderModelBinding; use devo_protocol::ProviderVendor; use devo_protocol::ProviderWireApi; +use devo_protocol::ReasoningEffort; +use devo_protocol::TruncationPolicyConfig; fn unique_temp_dir(name: &str) -> PathBuf { let nanos = SystemTime::now() @@ -560,6 +563,84 @@ request_model = "project/model" let _ = std::fs::remove_dir_all(root); } +#[test] +fn loader_merges_model_overrides_field_by_field_across_layers() { + let root = unique_temp_dir("config-model-overrides-overlay"); + let home = root.join("home").join(".devo"); + let workspace = root.join("workspace"); + std::fs::create_dir_all(&home).expect("home config dir"); + std::fs::create_dir_all(workspace.join(".devo")).expect("workspace config dir"); + + std::fs::write( + home.join("config.toml"), + r#" +[model.grok-4] +display_name = "User Grok" +description = "User description" +context_window = 128000 +temperature = 0.4 +provider = "openai_chat_completions" +default_reasoning_effort = "medium" +truncation_policy = { mode = "tokens", limit = 8000 } +"#, + ) + .expect("write user config"); + std::fs::write( + workspace.join(".devo").join("config.toml"), + r#" +[model.grok-4] +description = "Workspace description" +context_window = 192000 +top_p = 0.9 +"#, + ) + .expect("write workspace config"); + let cli_overrides: toml::Value = r#" +[model.grok-4] +display_name = "CLI Grok" +temperature = 0.2 + +[model.grok-4-mini] +display_name = "Grok 4 Mini" +max_tokens = 4096 +"# + .parse() + .expect("parse cli overrides"); + + let loader = FileSystemAppConfigLoader::new(home).with_cli_overrides(cli_overrides); + let config = loader.load(Some(&workspace)).expect("load config"); + + assert_eq!( + config.provider.model_overrides, + BTreeMap::from([ + ( + "grok-4".to_string(), + ModelOverrideConfig { + display_name: Some("CLI Grok".to_string()), + description: Some("Workspace description".to_string()), + context_window: Some(192_000), + temperature: Some(0.2), + top_p: Some(0.9), + provider: Some(ProviderWireApi::OpenAIChatCompletions), + default_reasoning_effort: Some(ReasoningEffort::Medium), + truncation_policy: Some(TruncationPolicyConfig::tokens(8_000)), + ..ModelOverrideConfig::default() + }, + ), + ( + "grok-4-mini".to_string(), + ModelOverrideConfig { + display_name: Some("Grok 4 Mini".to_string()), + max_tokens: Some(4_096), + ..ModelOverrideConfig::default() + }, + ), + ]) + ); + + let _ = std::fs::remove_dir_all(root); +} + /// Trace: L2-DES-APP-005 /// Verifies: CLI provider overrides participate in the same provider merge precedence as other CLI config. #[test] @@ -687,6 +768,7 @@ fn provider_upsert_writes_user_config_when_workspace_is_active() { assert!(user_config.contains("[providers.openrouter]")); assert!(user_config.contains("[model_bindings.qwen-openrouter]")); assert!(user_config.contains("model_binding = \"qwen-openrouter\"")); + assert!(document.get("model").is_none()); assert_eq!( document["providers"]["openrouter"]["headers"].as_str(), Some(r#"{"X-Devo":"yes"}"#) diff --git a/crates/core/src/model_catalog.rs b/crates/core/src/model_catalog.rs index 6605deca..dca336c8 100644 --- a/crates/core/src/model_catalog.rs +++ b/crates/core/src/model_catalog.rs @@ -1,129 +1,68 @@ //! Builtin model catalog loading and resolution for core. //! -//! Main focus: -//! - load the bundled preset list from disk-independent embedded assets -//! - load per-user and per-project model overrides from the filesystem -//! - convert raw `ModelPreset` values into runtime `Model` values -//! - provide the concrete builtin implementation of the shared `ModelCatalog` trait -//! -//! Design: -//! - catalog loading stays in `devo-core` because the embedded assets live here -//! - this module is the bridge between raw preset/config data and runtime model consumers -//! - models are sorted and materialized here so downstream code can work only with resolved `Model` -//! - precedence is: `/.devo/models.json` > `~/.devo/models.json` > builtin -//! -//! Boundary: -//! - this module should not define the runtime model shape itself; that lives in `devo-protocol` -//! - serde compatibility for the raw preset file belongs in `model_preset.rs` -//! - execution logic should depend on `ModelCatalog` and `Model`, not on how this module reads JSON -//! -use std::path::{Path, PathBuf}; +//! The embedded `models.json` asset is the catalog base. Configuration can +//! override individual metadata fields or add custom models without creating +//! or reading a filesystem catalog. +use std::collections::BTreeMap; use crate::{Model, ModelCatalog, ModelError, ModelPreset}; -use serde_json::Value; - -mod user_sync; +use devo_config::ModelOverrideConfig; const BUILTIN_MODELS_JSON: &str = include_str!("../models.json"); pub use crate::model_preset::default_base_instructions; -/// Filesystem-independent loader for the built-in model catalog bundled with the binary. -/// -/// Use [`PresetModelCatalog::load_from_config`] to include user and project overrides. -/// Use [`PresetModelCatalog::load`] for the builtin-only variant (tests, doctor, etc.). +/// A catalog resolved from embedded presets and configuration overrides. #[derive(Debug, Clone, Default)] pub struct PresetModelCatalog { models: Vec, - warnings: Vec, -} - -/// Non-fatal filesystem catalog issue recorded while loading `models.json`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ModelCatalogWarning { - pub path: PathBuf, - pub message: String, } impl PresetModelCatalog { - /// Loads the built-in catalog only (no filesystem overrides). + /// Loads the built-in embedded catalog only. pub fn load() -> Result { Ok(Self { models: load_builtin_models()?, - warnings: Vec::new(), }) } - /// Loads the effective catalog from three layers. Precedence is: - /// 1. `/.devo/models.json` (project overrides) - /// 2. `config_home/models.json` (user overrides) - /// 3. built-in models (embedded fallback) - /// - /// Implementation loads from fallback to highest precedence so later - /// layers can replace entries with the same slug. - /// - /// The user file is synchronized with the built-in list while preserving - /// pinned, edited, and user-defined entries. + /// Loads the embedded catalog with configured metadata overrides. pub fn load_from_config( - config_home: &Path, - workspace_root: Option<&Path>, + model_overrides: &BTreeMap, + ) -> Result { + Self::with_model_overrides(model_overrides) + } + + /// Loads embedded presets and applies overrides by model slug. + pub fn with_model_overrides( + model_overrides: &BTreeMap, ) -> Result { let mut presets = load_builtin_model_presets()?; - let mut warnings = Vec::new(); - let user_path = config_home.join("models.json"); - let project_path = - workspace_root.map(|workspace_root| workspace_root.join(".devo").join("models.json")); - let user_path_is_workspace_owned = project_path - .as_ref() - .is_some_and(|project_path| catalog_paths_alias(&user_path, project_path)); - - if !user_path_is_workspace_owned { - let builtin_entries: Vec = serde_json::from_str(BUILTIN_MODELS_JSON)?; - let user_sync = user_sync::synchronize_user_catalog(&user_path, &builtin_entries); - if let Some(user_presets) = user_sync.presets { - presets = merge_model_presets(presets, user_presets); - } - for message in user_sync.warnings { - tracing::warn!( - path = %user_path.display(), - warning = %message, - "model catalog synchronization warning" - ); - warnings.push(ModelCatalogWarning { - path: user_path.clone(), - message, - }); + for (slug, overrides) in model_overrides { + if let Some(preset) = presets.iter_mut().find(|preset| preset.slug == *slug) { + preset.apply_overrides(overrides); + } else { + presets.push(ModelPreset::from_overrides(slug, overrides)); } } - if let Some(project_path) = project_path { - merge_filesystem_model_presets(&mut presets, &mut warnings, &project_path); - } - + // `sort_by` is stable, keeping custom zero-priority entries after the + // embedded entries that were loaded first. presets.sort_by(|left, right| right.priority.cmp(&left.priority)); Ok(Self { models: presets.into_iter().map(Model::from).collect(), - warnings, }) } /// Creates a catalog from an already-loaded model list. pub fn new(models: Vec) -> Self { - Self { - models, - warnings: Vec::new(), - } + Self { models } } /// Returns the loaded models by value. pub fn into_inner(self) -> Vec { self.models } - - /// Returns non-fatal warnings encountered while loading filesystem overrides. - pub fn warnings(&self) -> &[ModelCatalogWarning] { - &self.warnings - } } impl ModelCatalog for PresetModelCatalog { @@ -162,86 +101,6 @@ pub fn load_builtin_models() -> Result, PresetModelCatalogError> { Ok(presets.into_iter().map(Model::from).collect()) } -/// Reads model presets from a filesystem JSON path. Missing files return `None`; -/// invalid files return an error so callers can warn while continuing. -fn load_models_from_file(path: &Path) -> Result>, ModelCatalogFileError> { - let contents = match std::fs::read_to_string(path) { - Ok(contents) => contents, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(ModelCatalogFileError::Read(error)), - }; - if contents.trim().is_empty() { - return Ok(Some(Vec::new())); - } - serde_json::from_str(&contents) - .map(Some) - .map_err(ModelCatalogFileError::Parse) -} - -fn catalog_paths_alias(left: &Path, right: &Path) -> bool { - if left == right { - return true; - } - if let (Ok(left), Ok(right)) = (std::fs::canonicalize(left), std::fs::canonicalize(right)) - && left == right - { - return true; - } - if left.file_name() != right.file_name() { - return false; - } - - match (left.parent(), right.parent()) { - (Some(left_parent), Some(right_parent)) => { - match ( - std::fs::canonicalize(left_parent), - std::fs::canonicalize(right_parent), - ) { - (Ok(left_parent), Ok(right_parent)) => left_parent == right_parent, - _ => false, - } - } - _ => false, - } -} - -fn merge_filesystem_model_presets( - presets: &mut Vec, - warnings: &mut Vec, - path: &Path, -) { - match load_models_from_file(path) { - Ok(Some(overrides)) => { - *presets = merge_model_presets(std::mem::take(presets), overrides); - } - Ok(None) => {} - Err(error) => { - let message = error.to_string(); - tracing::warn!( - path = %path.display(), - error = %message, - "skipping invalid model catalog override" - ); - warnings.push(ModelCatalogWarning { - path: path.to_path_buf(), - message, - }); - } - } -} - -/// Merges two model lists by slug. Entries from `overlay` replace matching -/// entries in `base`; entries with new slugs are appended. -fn merge_model_presets(mut base: Vec, overlay: Vec) -> Vec { - for entry in overlay { - match base.iter_mut().find(|m| m.slug == entry.slug) { - Some(existing) => *existing = entry, - None => base.push(entry), - } - } - base -} - /// Errors produced while loading the builtin catalog. #[derive(Debug, thiserror::Error)] pub enum PresetModelCatalogError { @@ -250,52 +109,20 @@ pub enum PresetModelCatalogError { Parse(#[from] serde_json::Error), } -#[derive(Debug, thiserror::Error)] -enum ModelCatalogFileError { - #[error("failed to read model catalog: {0}")] - Read(#[from] std::io::Error), - #[error("failed to parse model catalog: {0}")] - Parse(#[from] serde_json::Error), -} - #[cfg(test)] mod tests { - use std::path::PathBuf; - use std::time::{SystemTime, UNIX_EPOCH}; + use std::collections::BTreeMap; use pretty_assertions::assert_eq; use super::{ - PresetModelCatalog, default_base_instructions, load_builtin_models, merge_model_presets, + PresetModelCatalog, default_base_instructions, load_builtin_model_presets, + load_builtin_models, + }; + use crate::{ + InputModality, Model, ModelCatalog, ModelOverrideConfig, ProviderWireApi, + ReasoningCapability, ReasoningEffort, ReasoningImplementation, TruncationPolicyConfig, }; - use crate::{ModelCatalog, ModelPreset}; - - fn unique_temp_dir(name: &str) -> PathBuf { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time") - .as_nanos(); - let path = std::env::temp_dir().join(format!("devo-{name}-{nanos}")); - std::fs::create_dir_all(&path).expect("create temp dir"); - path - } - - fn make_preset(slug: &str, display_name: &str, priority: i32) -> ModelPreset { - ModelPreset { - slug: slug.into(), - display_name: display_name.into(), - priority, - ..ModelPreset::default() - } - } - - fn model_by_slug(models: &[crate::Model], slug: &str) -> crate::Model { - models - .iter() - .find(|model| model.slug == slug) - .cloned() - .expect("model exists") - } #[test] fn builtin_models_load_from_bundled_json() { @@ -317,362 +144,235 @@ mod tests { } #[test] - fn merge_by_slug_overrides_existing() { - let base = vec![make_preset("a", "Base A", 10)]; - let overlay = vec![make_preset("a", "Overlay A", 20)]; - let merged = merge_model_presets(base, overlay); - assert_eq!(merged.len(), 1); - assert_eq!(merged[0].display_name, "Overlay A"); - assert_eq!(merged[0].priority, 20); + fn builtin_models_have_channel_fields() { + let models = load_builtin_models().expect("load builtin models"); + assert!( + models + .iter() + .any(|model| model.channel.as_deref() == Some("DeepSeek")) + ); } #[test] - fn merge_by_slug_appends_new() { - let base = vec![make_preset("a", "A", 10)]; - let overlay = vec![make_preset("b", "B", 20)]; - let merged = merge_model_presets(base, overlay); - assert_eq!(merged.len(), 2); - assert_eq!(merged[0].slug, "a"); - assert_eq!(merged[1].slug, "b"); - } + fn load_from_config_applies_partial_builtin_override_without_replacing_metadata() { + let builtin = load_builtin_models() + .expect("load builtins") + .into_iter() + .find(|model| model.slug == "qwen3-coder-next") + .expect("qwen model"); + let catalog = PresetModelCatalog::load_from_config(&BTreeMap::from([( + "qwen3-coder-next".to_string(), + ModelOverrideConfig { + display_name: Some("Configured Qwen".to_string()), + ..ModelOverrideConfig::default() + }, + )])) + .expect("load catalog"); - #[test] - fn merge_empty_overlay_does_nothing() { - let base = vec![make_preset("a", "A", 10)]; - let merged = merge_model_presets(base, Vec::new()); - assert_eq!(merged.len(), 1); - assert_eq!(merged[0].display_name, "A"); + assert_eq!( + catalog.get("qwen3-coder-next").expect("configured qwen"), + &Model { + display_name: "Configured Qwen".to_string(), + ..builtin + } + ); } #[test] - fn load_from_config_returns_builtin_when_no_filesystem_files() { - let root = unique_temp_dir("catalog-builtin-only"); - let home = root.join("home").join(".devo"); - std::fs::create_dir_all(&home).expect("create home"); - - let catalog = - PresetModelCatalog::load_from_config(&home, /*workspace_root*/ None).expect("load"); - let models = catalog.into_inner(); - assert!(!models.is_empty()); - assert_eq!(models[0].slug, "qwen3-coder-next"); - - let _ = std::fs::remove_dir_all(root); - } + fn load_from_config_keeps_explicit_toggle_from_legacy_toggle_with_levels() { + let builtin = load_builtin_models() + .expect("load builtins") + .into_iter() + .find(|model| model.slug == "glm-5.2") + .expect("glm model"); + assert!(matches!( + builtin.reasoning_capability, + ReasoningCapability::ToggleWithLevels(_) + )); + + let catalog = PresetModelCatalog::load_from_config(&BTreeMap::from([( + "glm-5.2".to_string(), + ModelOverrideConfig { + reasoning_capability: Some(ReasoningCapability::Toggle), + ..ModelOverrideConfig::default() + }, + )])) + .expect("load catalog"); - #[test] - fn load_from_config_creates_managed_user_file_when_missing() { - let root = unique_temp_dir("catalog-seed"); - let home = root.join("home").join(".devo"); - std::fs::create_dir_all(&home).expect("create home"); - - let user_file = home.join("models.json"); - assert!(!user_file.exists()); - - let _catalog = - PresetModelCatalog::load_from_config(&home, /*workspace_root*/ None).expect("load"); - - assert!(user_file.exists()); - let contents = std::fs::read_to_string(&user_file).expect("read"); - let entries: Vec = - serde_json::from_str(&contents).expect("parse managed user catalog"); - assert!(!entries.is_empty()); - assert!(entries.iter().all(|entry| { - entry["_devo"]["update_policy"] == "managed" - && entry["_devo"]["builtin_sha256"] - .as_str() - .is_some_and(|hash| hash.starts_with("sha256:")) - })); - - let _ = std::fs::remove_dir_all(root); + assert_eq!( + catalog.get("glm-5.2").expect("configured glm"), + &Model { + reasoning_capability: ReasoningCapability::Toggle, + ..builtin + } + ); } #[test] - fn load_from_config_preserves_custom_models_when_appending_builtins() { - let root = unique_temp_dir("catalog-no-overwrite"); - let home = root.join("home").join(".devo"); - std::fs::create_dir_all(&home).expect("create home"); - - let user_file = home.join("models.json"); - std::fs::write( - &user_file, - "[{\"slug\":\"custom\",\"display_name\":\"Custom\"}]", - ) - .expect("write"); - - let catalog = - PresetModelCatalog::load_from_config(&home, /*workspace_root*/ None).expect("load"); - let models = catalog.into_inner(); + fn load_from_config_applies_complete_metadata_override() { + let catalog = PresetModelCatalog::load_from_config(&BTreeMap::from([( + "qwen3-coder-next".to_string(), + ModelOverrideConfig { + display_name: Some("Configured Qwen".to_string()), + description: Some("Configured description".to_string()), + context_window: Some(128_000), + effective_context_window_percent: Some(80), + max_tokens: Some(8_192), + temperature: Some(0.4), + top_p: Some(0.7), + top_k: Some(24.0), + provider: Some(ProviderWireApi::AnthropicMessages), + reasoning_capability: Some(ReasoningCapability::Levels(vec![ + ReasoningEffort::Low, + ReasoningEffort::High, + ])), + reasoning_implementation: Some(ReasoningImplementation::RequestParameter), + default_reasoning_effort: Some(ReasoningEffort::High), + base_instructions: Some("Configured instructions".to_string()), + input_modalities: Some(vec![InputModality::Image]), + channel: Some("Configured channel".to_string()), + truncation_policy: Some(TruncationPolicyConfig::tokens(4_096)), + supports_image_detail_original: Some(true), + }, + )])) + .expect("load catalog"); - assert!(models.iter().any(|m| m.slug == "custom")); - assert!(models.iter().any(|m| m.slug == "qwen3-coder-next")); - let entries: Vec = serde_json::from_str( - &std::fs::read_to_string(&user_file).expect("read synchronized user catalog"), - ) - .expect("parse synchronized user catalog"); assert_eq!( - entries[0], - serde_json::json!({"slug": "custom", "display_name": "Custom"}) + catalog.get("qwen3-coder-next").expect("configured qwen"), + &Model { + slug: "qwen3-coder-next".to_string(), + display_name: "Configured Qwen".to_string(), + provider: ProviderWireApi::AnthropicMessages, + description: Some("Configured description".to_string()), + reasoning_capability: ReasoningCapability::Levels(vec![ + ReasoningEffort::Low, + ReasoningEffort::High, + ]), + default_reasoning_effort: Some(ReasoningEffort::High), + reasoning_implementation: Some(ReasoningImplementation::RequestParameter), + base_instructions: "Configured instructions".to_string(), + context_window: 128_000, + effective_context_window_percent: Some(80), + truncation_policy: TruncationPolicyConfig::tokens(4_096), + input_modalities: vec![InputModality::Image], + supports_image_detail_original: true, + channel: Some("Configured channel".to_string()), + temperature: Some(0.4), + top_p: Some(0.7), + top_k: Some(24.0), + max_tokens: Some(8_192), + } ); - - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn load_from_config_applies_user_model_token_overrides() { - let root = unique_temp_dir("catalog-user-token-overrides"); - let home = root.join("home").join(".devo"); - std::fs::create_dir_all(&home).expect("create home"); - - std::fs::write( - home.join("models.json"), - r#"[ - { - "slug": "qwen3-coder-next", - "display_name": "Custom Qwen", - "context_window": 123456, - "effective_context_window_percent": 77, - "max_tokens": 7654 - } - ]"#, - ) - .expect("write user models"); - - let catalog = - PresetModelCatalog::load_from_config(&home, /*workspace_root*/ None).expect("load"); - let model = model_by_slug(&catalog.into_inner(), "qwen3-coder-next"); - - assert_eq!(model.context_window, 123456); - assert_eq!(model.effective_context_window_percent, Some(77)); - assert_eq!(model.max_tokens, Some(7654)); - - let _ = std::fs::remove_dir_all(root); } #[test] - fn load_from_config_missing_base_instructions_fall_back_to_default() { - let root = unique_temp_dir("catalog-missing-base-instructions"); - let home = root.join("home").join(".devo"); - std::fs::create_dir_all(&home).expect("create home"); - - std::fs::write( - home.join("models.json"), - r#"[ - { - "slug": "qwen3-coder-next", - "display_name": "Custom Qwen" - } - ]"#, - ) - .expect("write user models"); - - let catalog = - PresetModelCatalog::load_from_config(&home, /*workspace_root*/ None).expect("load"); - let model = model_by_slug(&catalog.into_inner(), "qwen3-coder-next"); - - assert_eq!(model.display_name, "Custom Qwen"); - assert_eq!(model.base_instructions, default_base_instructions()); - - let _ = std::fs::remove_dir_all(root); - } + fn load_from_config_creates_minimal_custom_model_with_fallback_instructions() { + let catalog = PresetModelCatalog::load_from_config(&BTreeMap::from([( + "custom".to_string(), + ModelOverrideConfig::default(), + )])) + .expect("load catalog"); - #[test] - fn load_from_config_project_overrides_user_by_slug() { - let root = unique_temp_dir("catalog-project-wins"); - let home = root.join("home").join(".devo"); - let workspace = root.join("workspace"); - std::fs::create_dir_all(&home).expect("create home"); - std::fs::create_dir_all(workspace.join(".devo")).expect("create project"); - - std::fs::write( - home.join("models.json"), - r#"[{"slug":"custom","display_name":"User","context_window":111,"effective_context_window_percent":66,"max_tokens":222}]"#, - ) - .expect("write user models"); - std::fs::write( - workspace.join(".devo").join("models.json"), - r#"[{"slug":"custom","display_name":"Project","context_window":333,"effective_context_window_percent":88,"max_tokens":444}]"#, - ) - .expect("write project models"); - - let catalog = PresetModelCatalog::load_from_config(&home, Some(&workspace)).expect("load"); - let model = model_by_slug(&catalog.into_inner(), "custom"); - - assert_eq!(model.display_name, "Project"); - assert_eq!(model.context_window, 333); - assert_eq!(model.effective_context_window_percent, Some(88)); - assert_eq!(model.max_tokens, Some(444)); - - let _ = std::fs::remove_dir_all(root); + assert_eq!( + catalog.get("custom").expect("custom model"), + &Model { + slug: "custom".to_string(), + display_name: "custom".to_string(), + base_instructions: default_base_instructions().to_string(), + ..Model::default() + } + ); } #[test] - fn load_from_config_does_not_sync_catalog_shared_by_user_and_workspace_paths() { - let root = unique_temp_dir("catalog-shared-user-workspace"); - let workspace = root.join("workspace"); - let shared_home = workspace.join(".devo"); - std::fs::create_dir_all(&shared_home).expect("create shared catalog directory"); - let shared_catalog = shared_home.join("models.json"); - let contents = r#"[{"slug":"qwen3-coder-next","display_name":"Workspace-owned catalog"}]"#; - std::fs::write(&shared_catalog, contents).expect("write shared catalog"); - - let catalog = - PresetModelCatalog::load_from_config(&shared_home, Some(&workspace)).expect("load"); + fn load_from_config_creates_fully_specified_custom_model() { + let catalog = PresetModelCatalog::with_model_overrides(&BTreeMap::from([( + "custom".to_string(), + ModelOverrideConfig { + display_name: Some("Custom".to_string()), + description: Some("Custom description".to_string()), + context_window: Some(64_000), + effective_context_window_percent: Some(75), + max_tokens: Some(4_096), + temperature: Some(0.2), + top_p: Some(0.6), + top_k: Some(12.0), + provider: Some(ProviderWireApi::OpenAIResponses), + reasoning_capability: Some(ReasoningCapability::Toggle), + reasoning_implementation: Some(ReasoningImplementation::RequestParameter), + default_reasoning_effort: Some(ReasoningEffort::Medium), + base_instructions: Some("Custom instructions".to_string()), + input_modalities: Some(vec![InputModality::Text, InputModality::Image]), + channel: Some("Custom channel".to_string()), + truncation_policy: Some(TruncationPolicyConfig::tokens(2_048)), + supports_image_detail_original: Some(true), + }, + )])) + .expect("load catalog"); assert_eq!( - catalog - .get("qwen3-coder-next") - .expect("workspace model") - .display_name, - "Workspace-owned catalog" - ); - assert_eq!( - std::fs::read_to_string(&shared_catalog).expect("read shared catalog"), - contents + catalog.get("custom").expect("custom model"), + &Model { + slug: "custom".to_string(), + display_name: "Custom".to_string(), + provider: ProviderWireApi::OpenAIResponses, + description: Some("Custom description".to_string()), + reasoning_capability: ReasoningCapability::Toggle, + default_reasoning_effort: Some(ReasoningEffort::Medium), + reasoning_implementation: Some(ReasoningImplementation::RequestParameter), + base_instructions: "Custom instructions".to_string(), + context_window: 64_000, + effective_context_window_percent: Some(75), + truncation_policy: TruncationPolicyConfig::tokens(2_048), + input_modalities: vec![InputModality::Text, InputModality::Image], + supports_image_detail_original: true, + channel: Some("Custom channel".to_string()), + temperature: Some(0.2), + top_p: Some(0.6), + top_k: Some(12.0), + max_tokens: Some(4_096), + } ); - - let _ = std::fs::remove_dir_all(root); } - #[cfg(unix)] #[test] - fn load_from_config_does_not_sync_symlinked_workspace_catalog() { - use std::os::unix::fs::symlink; - - let root = unique_temp_dir("catalog-symlinked-user-workspace"); - let config_home = root.join("catalog"); - let workspace = root.join("workspace"); - std::fs::create_dir_all(&config_home).expect("create catalog directory"); - std::fs::create_dir_all(&workspace).expect("create workspace"); - symlink(&config_home, workspace.join(".devo")).expect("symlink workspace catalog"); - let shared_catalog = config_home.join("models.json"); - let contents = - r#"[{"slug":"qwen3-coder-next","display_name":"Symlinked workspace catalog"}]"#; - std::fs::write(&shared_catalog, contents).expect("write shared catalog"); - - let catalog = - PresetModelCatalog::load_from_config(&config_home, Some(&workspace)).expect("load"); + fn load_from_config_keeps_explicit_empty_base_instructions() { + let catalog = PresetModelCatalog::load_from_config(&BTreeMap::from([( + "custom".to_string(), + ModelOverrideConfig { + base_instructions: Some(String::new()), + ..ModelOverrideConfig::default() + }, + )])) + .expect("load catalog"); assert_eq!( catalog - .get("qwen3-coder-next") - .expect("workspace model") - .display_name, - "Symlinked workspace catalog" - ); - assert_eq!( - std::fs::read_to_string(&shared_catalog).expect("read shared catalog"), - contents + .get("custom") + .expect("custom model") + .base_instructions, + "" ); - - let _ = std::fs::remove_dir_all(root); } #[test] - fn load_from_config_uses_workspace_user_builtin_precedence_by_slug() { - let root = unique_temp_dir("catalog-precedence"); - let home = root.join("home").join(".devo"); - let workspace = root.join("workspace"); - std::fs::create_dir_all(&home).expect("create home"); - std::fs::create_dir_all(workspace.join(".devo")).expect("create project"); - - let user_models = home.join("models.json"); - let workspace_models = workspace.join(".devo").join("models.json"); - std::fs::write( - &user_models, - r#"[{"slug":"qwen3-coder-next","display_name":"User","context_window":111,"effective_context_window_percent":66,"max_tokens":222}]"#, - ) - .expect("write user models"); - std::fs::write( - &workspace_models, - r#"[{"slug":"qwen3-coder-next","display_name":"Workspace","context_window":333,"effective_context_window_percent":88,"max_tokens":444}]"#, - ) - .expect("write project models"); - let workspace_contents = - std::fs::read_to_string(&workspace_models).expect("read project models before load"); - - let catalog = PresetModelCatalog::load_from_config(&home, Some(&workspace)).expect("load"); - let model = model_by_slug(&catalog.into_inner(), "qwen3-coder-next"); - - assert_eq!( - model, - crate::Model::from(ModelPreset { - slug: "qwen3-coder-next".into(), - display_name: "Workspace".into(), - context_window: 333, - effective_context_window_percent: Some(88), - input_modalities: vec![crate::InputModality::Text, crate::InputModality::Image], - max_tokens: Some(444), - ..ModelPreset::default() - }) - ); - assert_eq!( - std::fs::read_to_string(&workspace_models).expect("read project models after load"), - workspace_contents - ); + fn custom_models_follow_builtins_with_equal_priority() { + let catalog = PresetModelCatalog::load_from_config(&BTreeMap::from([( + "custom".to_string(), + ModelOverrideConfig::default(), + )])) + .expect("load catalog"); + let models = catalog.into_inner(); - let _ = std::fs::remove_dir_all(root); + assert_eq!(models.last().expect("custom model").slug, "custom"); } #[test] - fn load_from_config_records_warning_and_continues_for_invalid_filesystem_catalog() { - let root = unique_temp_dir("catalog-invalid-warning"); - let home = root.join("home").join(".devo"); - std::fs::create_dir_all(&home).expect("create home"); - let user_file = home.join("models.json"); - std::fs::write(&user_file, "{not valid json").expect("write invalid user models"); - - let catalog = - PresetModelCatalog::load_from_config(&home, /*workspace_root*/ None).expect("load"); - - assert!(catalog.get("qwen3-coder-next").is_some()); - assert_eq!(catalog.warnings().len(), 1); - assert_eq!(catalog.warnings()[0].path, user_file); + fn embedded_presets_remain_the_only_catalog_base() { assert!( - catalog.warnings()[0] - .message - .contains("failed to parse model catalog") + !load_builtin_model_presets() + .expect("load embedded presets") + .is_empty() ); - - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn builtin_models_have_channel_fields() { - let models = load_builtin_models().expect("load builtin models"); - let deepseek_models: Vec<_> = models - .iter() - .filter(|m| m.channel.as_deref() == Some("DeepSeek")) - .collect(); - assert!(!deepseek_models.is_empty()); - assert!(deepseek_models.iter().any(|m| m.slug == "deepseek-v4-pro")); - } - - #[test] - fn load_from_config_preserves_explicit_base_instructions() { - let root = unique_temp_dir("catalog-preserve-base-instructions"); - let home = root.join("home").join(".devo"); - std::fs::create_dir_all(&home).expect("create home"); - - std::fs::write( - home.join("models.json"), - r#"[ - { - "slug": "qwen3-coder-next", - "display_name": "Custom Qwen", - "base_instructions": "Custom catalog instructions" - } - ]"#, - ) - .expect("write user models"); - - let catalog = - PresetModelCatalog::load_from_config(&home, /*workspace_root*/ None).expect("load"); - let model = model_by_slug(&catalog.into_inner(), "qwen3-coder-next"); - - assert_eq!(model.display_name, "Custom Qwen"); - assert_eq!(model.base_instructions, "Custom catalog instructions"); - assert_ne!(model.base_instructions, default_base_instructions()); - - let _ = std::fs::remove_dir_all(root); } } diff --git a/crates/core/src/model_catalog/user_sync.rs b/crates/core/src/model_catalog/user_sync.rs deleted file mode 100644 index 17e42172..00000000 --- a/crates/core/src/model_catalog/user_sync.rs +++ /dev/null @@ -1,638 +0,0 @@ -use std::collections::{HashMap, HashSet}; -use std::fs; -use std::io::Write; -use std::path::Path; - -use crate::ModelPreset; -use serde_json::{Map, Value}; -use sha2::{Digest, Sha256}; - -const METADATA_KEY: &str = "_devo"; -const BUILTIN_SHA256_KEY: &str = "builtin_sha256"; -const UPDATE_POLICY_KEY: &str = "update_policy"; -const MANAGED_POLICY: &str = "managed"; -const PINNED_POLICY: &str = "pinned"; - -#[derive(Debug)] -pub(super) struct UserCatalogSyncOutcome { - pub(super) presets: Option>, - pub(super) warnings: Vec, -} - -pub(super) fn synchronize_user_catalog( - user_path: &Path, - builtin_entries: &[Value], -) -> UserCatalogSyncOutcome { - synchronize_user_catalog_with_writer(user_path, builtin_entries, write_atomic) -} - -fn synchronize_user_catalog_with_writer( - user_path: &Path, - builtin_entries: &[Value], - writer: F, -) -> UserCatalogSyncOutcome -where - F: FnOnce(&Path, &[u8]) -> std::io::Result<()>, -{ - let (mut user_entries, existed) = match read_user_entries(user_path) { - Ok(entries) => entries, - Err(message) => { - return UserCatalogSyncOutcome { - presets: None, - warnings: vec![message], - }; - } - }; - - if let Err(error) = parse_presets(&user_entries) { - return UserCatalogSyncOutcome { - presets: None, - warnings: vec![format!("failed to parse model catalog: {error}")], - }; - } - - let original_entries = user_entries.clone(); - synchronize_entries(&mut user_entries, builtin_entries); - - let presets = match parse_presets(&user_entries) { - Ok(presets) => presets, - Err(error) => { - return UserCatalogSyncOutcome { - presets: None, - warnings: vec![format!( - "failed to parse synchronized model catalog: {error}" - )], - }; - } - }; - - let mut warnings = Vec::new(); - if !existed || user_entries != original_entries { - match serde_json::to_string_pretty(&user_entries) { - Ok(mut serialized) => { - serialized.push('\n'); - if let Err(error) = writer(user_path, serialized.as_bytes()) { - warnings.push(format!( - "failed to persist synchronized model catalog: {error}" - )); - } - } - Err(error) => warnings.push(format!( - "failed to serialize synchronized model catalog: {error}" - )), - } - } - - UserCatalogSyncOutcome { - presets: Some(presets), - warnings, - } -} - -fn read_user_entries(path: &Path) -> Result<(Vec, bool), String> { - let contents = match fs::read_to_string(path) { - Ok(contents) => contents, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Ok((Vec::new(), false)); - } - Err(error) => return Err(format!("failed to read model catalog: {error}")), - }; - - if contents.trim().is_empty() { - return Ok((Vec::new(), true)); - } - - serde_json::from_str(&contents) - .map(|entries| (entries, true)) - .map_err(|error| format!("failed to parse model catalog: {error}")) -} - -fn parse_presets(entries: &[Value]) -> Result, serde_json::Error> { - serde_json::from_value(Value::Array(entries.to_vec())) -} - -fn synchronize_entries(user_entries: &mut Vec, builtin_entries: &[Value]) { - let builtins_by_slug: HashMap<&str, (&Value, String)> = builtin_entries - .iter() - .filter_map(|entry| { - let slug = entry.get("slug")?.as_str()?; - Some((slug, (entry, fingerprint(entry)))) - }) - .collect(); - let mut existing_slugs = HashSet::new(); - - for user_entry in user_entries.iter_mut() { - let Some(slug) = user_entry - .get("slug") - .and_then(Value::as_str) - .map(str::to_owned) - else { - continue; - }; - existing_slugs.insert(slug.clone()); - - let Some((builtin_entry, builtin_hash)) = builtins_by_slug.get(slug.as_str()) else { - continue; - }; - - match management_state(user_entry) { - EntryManagement::Pinned => {} - EntryManagement::Managed { source_hash } => { - let user_hash = fingerprint(user_entry); - if user_hash == source_hash { - *user_entry = - managed_builtin_entry(builtin_entry, Some(user_entry), builtin_hash); - } - } - EntryManagement::Legacy => { - if fingerprint(user_entry) == *builtin_hash { - *user_entry = - managed_builtin_entry(builtin_entry, Some(user_entry), builtin_hash); - } else { - pin_entry(user_entry); - } - } - } - } - - for builtin_entry in builtin_entries { - let Some(slug) = builtin_entry.get("slug").and_then(Value::as_str) else { - continue; - }; - if existing_slugs.insert(slug.to_owned()) { - let builtin_hash = fingerprint(builtin_entry); - user_entries.push(managed_builtin_entry( - builtin_entry, - /*previous_entry*/ None, - &builtin_hash, - )); - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum EntryManagement { - Legacy, - Managed { source_hash: String }, - Pinned, -} - -fn management_state(entry: &Value) -> EntryManagement { - let Some(metadata) = entry.get(METADATA_KEY).and_then(Value::as_object) else { - return EntryManagement::Legacy; - }; - let policy = metadata.get(UPDATE_POLICY_KEY).and_then(Value::as_str); - let source_hash = metadata - .get(BUILTIN_SHA256_KEY) - .and_then(Value::as_str) - .filter(|hash| is_valid_builtin_hash(hash)) - .map(str::to_owned); - - match (policy, source_hash) { - (Some(PINNED_POLICY), _) => EntryManagement::Pinned, - (Some(MANAGED_POLICY), Some(source_hash)) => EntryManagement::Managed { source_hash }, - (Some(MANAGED_POLICY), None) => EntryManagement::Legacy, - (Some(_), _) => EntryManagement::Pinned, - (None, Some(source_hash)) => EntryManagement::Managed { source_hash }, - (None, None) => EntryManagement::Legacy, - } -} - -fn is_valid_builtin_hash(hash: &str) -> bool { - hash.strip_prefix("sha256:").is_some_and(|digest| { - digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()) - }) -} - -fn fingerprint(entry: &Value) -> String { - let mut content = entry.clone(); - if let Some(object) = content.as_object_mut() { - object.remove(METADATA_KEY); - } - let canonical = canonicalize_json(&content); - let digest = Sha256::digest( - serde_json::to_vec(&canonical).expect("serializing serde_json::Value cannot fail"), - ); - format!("sha256:{digest:x}") -} - -fn canonicalize_json(value: &Value) -> Value { - match value { - Value::Array(values) => Value::Array(values.iter().map(canonicalize_json).collect()), - Value::Object(object) => { - let mut entries: Vec<_> = object.iter().collect(); - entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right)); - Value::Object( - entries - .into_iter() - .map(|(key, value)| (key.clone(), canonicalize_json(value))) - .collect(), - ) - } - Value::Number(number) => Value::Number(canonical_number(number)), - scalar => scalar.clone(), - } -} - -fn canonical_number(number: &serde_json::Number) -> serde_json::Number { - if let Some(value) = number.as_i64() { - return value.into(); - } - if let Some(value) = number.as_u64() { - return value.into(); - } - - let value = number - .as_f64() - .expect("serde_json numbers are finite numeric values"); - if value == 0.0 { - return 0.into(); - } - if value.fract() == 0.0 && value >= i64::MIN as f64 && value <= i64::MAX as f64 { - return (value as i64).into(); - } - serde_json::Number::from_f64(value).expect("serde_json numbers are finite") -} - -fn managed_builtin_entry( - builtin_entry: &Value, - previous_entry: Option<&Value>, - builtin_hash: &str, -) -> Value { - let mut managed = builtin_entry.clone(); - let object = managed - .as_object_mut() - .expect("built-in model entries must be JSON objects"); - object.remove(METADATA_KEY); - - let mut metadata = previous_entry - .and_then(|entry| entry.get(METADATA_KEY)) - .and_then(Value::as_object) - .cloned() - .unwrap_or_default(); - metadata.insert( - BUILTIN_SHA256_KEY.into(), - Value::String(builtin_hash.to_owned()), - ); - metadata.insert( - UPDATE_POLICY_KEY.into(), - Value::String(MANAGED_POLICY.into()), - ); - object.insert(METADATA_KEY.into(), Value::Object(metadata)); - managed -} - -fn pin_entry(entry: &mut Value) { - let object = entry - .as_object_mut() - .expect("validated model entries must be JSON objects"); - let metadata = object - .entry(METADATA_KEY) - .or_insert_with(|| Value::Object(Map::new())); - if !metadata.is_object() { - *metadata = Value::Object(Map::new()); - } - metadata.as_object_mut().expect("metadata object").insert( - UPDATE_POLICY_KEY.into(), - Value::String(PINNED_POLICY.into()), - ); -} - -fn write_atomic(path: &Path, data: &[u8]) -> std::io::Result<()> { - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - fs::create_dir_all(parent)?; - let mut temp_file = tempfile::NamedTempFile::new_in(parent)?; - temp_file.write_all(data)?; - temp_file.as_file().sync_all()?; - temp_file - .persist(path) - .map(|_persisted_file| ()) - .map_err(|error| error.error) -} - -#[cfg(test)] -mod tests { - use std::fs; - use std::path::Path; - - use crate::ModelPreset; - use pretty_assertions::assert_eq; - use serde_json::{Value, json}; - use sha2::{Digest, Sha256}; - use tempfile::TempDir; - - use super::{synchronize_user_catalog, synchronize_user_catalog_with_writer}; - - fn model(slug: &str, display_name: &str) -> Value { - json!({ - "slug": slug, - "display_name": display_name, - "provider": "openai_chat_completions", - "context_window": 200000, - "input_modalities": ["text"] - }) - } - - fn sha256(entry: &Value) -> String { - let mut canonical = entry.clone(); - canonical - .as_object_mut() - .expect("model entry object") - .remove("_devo"); - let digest = Sha256::digest(serde_json::to_vec(&canonical).expect("serialize model")); - format!("sha256:{digest:x}") - } - - fn managed(entry: &Value) -> Value { - let mut expected = entry.clone(); - expected - .as_object_mut() - .expect("model entry object") - .insert( - "_devo".into(), - json!({ - "builtin_sha256": sha256(entry), - "update_policy": "managed" - }), - ); - expected - } - - fn pinned(entry: &Value) -> Value { - let mut expected = entry.clone(); - expected - .as_object_mut() - .expect("model entry object") - .insert( - "_devo".into(), - json!({ - "update_policy": "pinned" - }), - ); - expected - } - - fn read_entries(path: &Path) -> Vec { - serde_json::from_str(&fs::read_to_string(path).expect("read catalog")) - .expect("parse catalog") - } - - fn write_entries(path: &Path, entries: &[Value]) { - fs::write( - path, - serde_json::to_string_pretty(entries).expect("serialize catalog"), - ) - .expect("write catalog"); - } - - #[test] - fn missing_user_catalog_creates_managed_builtin_copy() { - let temp = TempDir::new().expect("temp dir"); - let path = temp.path().join("nested").join("models.json"); - let builtins = vec![model("a", "Builtin A"), model("b", "Builtin B")]; - - let outcome = synchronize_user_catalog(&path, &builtins); - - assert_eq!(outcome.warnings, Vec::::new()); - assert!(outcome.presets.is_some()); - assert_eq!( - read_entries(&path), - builtins.iter().map(managed).collect::>() - ); - } - - #[test] - fn untouched_managed_entry_tracks_changed_builtin() { - let temp = TempDir::new().expect("temp dir"); - let path = temp.path().join("models.json"); - let previous = model("a", "Previous"); - write_entries(&path, &[managed(&previous)]); - let current = model("a", "Current"); - - let outcome = synchronize_user_catalog(&path, std::slice::from_ref(¤t)); - - assert_eq!(outcome.warnings, Vec::::new()); - assert_eq!(read_entries(&path), vec![managed(¤t)]); - } - - #[test] - fn edited_managed_entry_is_preserved() { - let temp = TempDir::new().expect("temp dir"); - let path = temp.path().join("models.json"); - let previous = model("a", "Previous"); - let mut edited = managed(&previous); - edited["display_name"] = json!("User edit"); - write_entries(&path, std::slice::from_ref(&edited)); - let current = model("a", "Current"); - - let outcome = synchronize_user_catalog(&path, &[current]); - - assert_eq!(outcome.warnings, Vec::::new()); - assert_eq!(read_entries(&path), vec![edited]); - } - - #[test] - fn explicitly_pinned_entry_is_preserved() { - let temp = TempDir::new().expect("temp dir"); - let path = temp.path().join("models.json"); - let user = pinned(&model("a", "Pinned")); - write_entries(&path, std::slice::from_ref(&user)); - - let outcome = synchronize_user_catalog(&path, &[model("a", "Current")]); - - assert_eq!(outcome.warnings, Vec::::new()); - assert_eq!(read_entries(&path), vec![user]); - } - - #[test] - fn invalid_managed_fingerprints_are_migrated_conservatively() { - let temp = TempDir::new().expect("temp dir"); - let path = temp.path().join("models.json"); - let mut missing = model("a", "Missing hash user edit"); - missing["_devo"] = json!({"update_policy": "managed"}); - let mut non_string = model("a", "Non-string hash user edit"); - non_string["_devo"] = json!({ - "builtin_sha256": 42, - "update_policy": "managed" - }); - let mut malformed = model("a", "Malformed hash user edit"); - malformed["_devo"] = json!({ - "builtin_sha256": "sha256:not-a-valid-digest", - "update_policy": "managed" - }); - write_entries( - &path, - &[missing.clone(), non_string.clone(), malformed.clone()], - ); - missing["_devo"]["update_policy"] = json!("pinned"); - non_string["_devo"]["update_policy"] = json!("pinned"); - malformed["_devo"]["update_policy"] = json!("pinned"); - - let outcome = synchronize_user_catalog(&path, &[model("a", "Builtin")]); - - assert_eq!(outcome.warnings, Vec::::new()); - assert_eq!(read_entries(&path), vec![missing, non_string, malformed]); - } - - #[test] - fn legacy_entries_are_classified_without_losing_custom_models() { - let temp = TempDir::new().expect("temp dir"); - let path = temp.path().join("models.json"); - let unchanged = model("a", "Builtin A"); - let changed = model("b", "User B"); - let custom = model("custom", "Custom"); - write_entries(&path, &[unchanged.clone(), changed.clone(), custom.clone()]); - let builtins = vec![unchanged.clone(), model("b", "Builtin B")]; - - let outcome = synchronize_user_catalog(&path, &builtins); - - assert_eq!(outcome.warnings, Vec::::new()); - assert_eq!( - read_entries(&path), - vec![managed(&unchanged), pinned(&changed), custom] - ); - } - - #[test] - fn new_builtins_are_appended_and_removed_builtins_are_preserved() { - let temp = TempDir::new().expect("temp dir"); - let path = temp.path().join("models.json"); - let removed = managed(&model("removed", "Removed")); - write_entries(&path, std::slice::from_ref(&removed)); - let added = model("added", "Added"); - - let outcome = synchronize_user_catalog(&path, std::slice::from_ref(&added)); - - assert_eq!(outcome.warnings, Vec::::new()); - assert_eq!(read_entries(&path), vec![removed, managed(&added)]); - } - - #[test] - fn json_formatting_and_key_order_do_not_count_as_edits() { - let temp = TempDir::new().expect("temp dir"); - let path = temp.path().join("models.json"); - let mut previous = model("a", "Previous"); - previous["nested"] = json!({"alpha": 1, "beta": 2}); - let previous_hash = sha256(&previous); - fs::write( - &path, - format!( - r#"[{{"provider":"openai_chat_completions","slug":"a","nested":{{"beta":2,"alpha":1}},"input_modalities":["text"],"context_window":200000,"display_name":"Previous","_devo":{{"update_policy":"managed","builtin_sha256":"{previous_hash}"}}}}]"# - ), - ) - .expect("write reordered catalog"); - let current = model("a", "Current"); - - let outcome = synchronize_user_catalog(&path, std::slice::from_ref(¤t)); - - assert_eq!(outcome.warnings, Vec::::new()); - assert_eq!(read_entries(&path), vec![managed(¤t)]); - } - - #[test] - fn equivalent_number_representations_do_not_count_as_edits() { - let temp = TempDir::new().expect("temp dir"); - let path = temp.path().join("models.json"); - let mut previous = model("a", "Previous"); - previous["temperature"] = json!(1); - let mut user_entry = managed(&previous); - user_entry["temperature"] = json!(1.0); - write_entries(&path, &[user_entry]); - let mut current = previous; - current["display_name"] = json!("Current"); - - let outcome = synchronize_user_catalog(&path, std::slice::from_ref(¤t)); - - assert_eq!(outcome.warnings, Vec::::new()); - assert_eq!(read_entries(&path), vec![managed(¤t)]); - } - - #[test] - fn unchanged_managed_catalog_is_not_rewritten() { - let temp = TempDir::new().expect("temp dir"); - let path = temp.path().join("models.json"); - let current = model("a", "Current"); - let managed_current = managed(¤t); - let compact = - serde_json::to_string(&vec![managed_current]).expect("serialize compact JSON"); - fs::write(&path, &compact).expect("write compact catalog"); - - let outcome = synchronize_user_catalog(&path, std::slice::from_ref(¤t)); - - assert_eq!(outcome.warnings, Vec::::new()); - assert_eq!(fs::read_to_string(&path).expect("read catalog"), compact); - } - - #[test] - fn managed_updates_preserve_unknown_metadata_fields() { - let temp = TempDir::new().expect("temp dir"); - let path = temp.path().join("models.json"); - let previous = model("a", "Previous"); - let mut managed_previous = managed(&previous); - managed_previous["_devo"]["future_metadata"] = json!({"keep": true}); - write_entries(&path, &[managed_previous]); - let current = model("a", "Current"); - let mut expected = managed(¤t); - expected["_devo"]["future_metadata"] = json!({"keep": true}); - - let outcome = synchronize_user_catalog(&path, std::slice::from_ref(¤t)); - - assert_eq!(outcome.warnings, Vec::::new()); - assert_eq!(read_entries(&path), vec![expected]); - } - - #[test] - fn unknown_model_fields_count_as_user_customization() { - let temp = TempDir::new().expect("temp dir"); - let path = temp.path().join("models.json"); - let previous = model("a", "Previous"); - let mut customized = managed(&previous); - customized["future_provider_option"] = json!({"enabled": true}); - write_entries(&path, std::slice::from_ref(&customized)); - - let outcome = synchronize_user_catalog(&path, &[model("a", "Current")]); - - assert_eq!(outcome.warnings, Vec::::new()); - assert_eq!(read_entries(&path), vec![customized]); - } - - #[test] - fn invalid_user_json_is_not_overwritten() { - let temp = TempDir::new().expect("temp dir"); - let path = temp.path().join("models.json"); - let invalid = "{not valid json"; - fs::write(&path, invalid).expect("write invalid catalog"); - - let outcome = synchronize_user_catalog(&path, &[model("a", "Builtin")]); - - assert_eq!(outcome.presets, None); - assert_eq!(outcome.warnings.len(), 1); - assert!(outcome.warnings[0].contains("failed to parse model catalog")); - assert_eq!( - fs::read_to_string(&path).expect("read invalid catalog"), - invalid - ); - } - - #[test] - fn write_failure_returns_synchronized_presets_and_warning() { - let temp = TempDir::new().expect("temp dir"); - let path = temp.path().join("models.json"); - let previous = model("a", "Previous"); - write_entries(&path, &[managed(&previous)]); - let current = model("a", "Current"); - - let outcome = synchronize_user_catalog_with_writer( - &path, - std::slice::from_ref(¤t), - |_path, _data| Err(std::io::Error::other("injected write failure")), - ); - - assert_eq!(outcome.warnings.len(), 1); - assert!(outcome.warnings[0].contains("failed to persist synchronized model catalog")); - assert_eq!(read_entries(&path), vec![managed(&previous)]); - let expected_presets: Vec = - serde_json::from_value(Value::Array(vec![current])).expect("parse current preset"); - assert_eq!(outcome.presets, Some(expected_presets)); - } -} diff --git a/crates/core/src/model_preset.rs b/crates/core/src/model_preset.rs index 8d39c28d..ec284962 100644 --- a/crates/core/src/model_preset.rs +++ b/crates/core/src/model_preset.rs @@ -15,6 +15,7 @@ //! - turn execution should consume `Model`, not `ModelPreset` //! - loading policy and catalog access live in `model_catalog.rs`; this file only defines the raw shape //! +use devo_config::ModelOverrideConfig; use devo_protocol::InputModality; use devo_protocol::Model; use devo_protocol::ProviderWireApi; @@ -132,6 +133,75 @@ impl Default for ModelPreset { } } +impl ModelPreset { + /// Applies configured metadata overrides while preserving fields that were omitted. + pub fn apply_overrides(&mut self, overrides: &ModelOverrideConfig) { + if let Some(display_name) = &overrides.display_name { + self.display_name.clone_from(display_name); + } + if let Some(description) = &overrides.description { + self.description = Some(description.clone()); + } + if let Some(context_window) = overrides.context_window { + self.context_window = context_window; + } + if let Some(effective_context_window_percent) = overrides.effective_context_window_percent { + self.effective_context_window_percent = Some(effective_context_window_percent); + } + if let Some(max_tokens) = overrides.max_tokens { + self.max_tokens = Some(max_tokens); + } + if let Some(temperature) = overrides.temperature { + self.temperature = Some(temperature); + } + if let Some(top_p) = overrides.top_p { + self.top_p = Some(top_p); + } + if let Some(top_k) = overrides.top_k { + self.top_k = Some(top_k); + } + if let Some(provider) = overrides.provider { + self.provider = provider; + } + if let Some(reasoning_capability) = &overrides.reasoning_capability { + self.reasoning_capability = reasoning_capability.clone(); + self.supported_reasoning_levels.clear(); + } + if let Some(reasoning_implementation) = &overrides.reasoning_implementation { + self.reasoning_implementation = Some(reasoning_implementation.clone()); + } + if let Some(default_reasoning_effort) = overrides.default_reasoning_effort { + self.default_reasoning_effort = Some(default_reasoning_effort); + } + if let Some(base_instructions) = &overrides.base_instructions { + self.base_instructions = Some(base_instructions.clone()); + } + if let Some(input_modalities) = &overrides.input_modalities { + self.input_modalities.clone_from(input_modalities); + } + if let Some(channel) = &overrides.channel { + self.channel = Some(channel.clone()); + } + if let Some(truncation_policy) = overrides.truncation_policy { + self.truncation_policy = truncation_policy; + } + if let Some(supports_image_detail_original) = overrides.supports_image_detail_original { + self.supports_image_detail_original = supports_image_detail_original; + } + } + + /// Creates a safe custom preset and applies its configured metadata overrides. + pub fn from_overrides(slug: &str, overrides: &ModelOverrideConfig) -> Self { + let mut preset = Self { + slug: slug.to_string(), + display_name: slug.to_string(), + ..Self::default() + }; + preset.apply_overrides(overrides); + preset + } +} + impl From for Model { fn from(value: ModelPreset) -> Self { let supported_reasoning_levels = value.supported_reasoning_levels; diff --git a/crates/server/src/bootstrap.rs b/crates/server/src/bootstrap.rs index 41c2365f..53c9d73b 100644 --- a/crates/server/src/bootstrap.rs +++ b/crates/server/src/bootstrap.rs @@ -201,8 +201,7 @@ pub async fn run_server_process( let tool_plan = ToolPlanConfig::from_app_config(&config); let registry = handlers::build_registry_from_plan_with_mcp(&tool_plan, mcp_manager).await; let model_catalog: Arc = Arc::new(PresetModelCatalog::load_from_config( - &resolver.user_config_dir(), - /*workspace_root*/ None, + &config.provider.model_overrides, )?); let default_model = model_catalog.resolve_for_turn(None)?.slug.clone(); if !config.has_provider_configuration() { diff --git a/crates/server/src/execution.rs b/crates/server/src/execution.rs index a109beab..f076ca00 100644 --- a/crates/server/src/execution.rs +++ b/crates/server/src/execution.rs @@ -372,16 +372,27 @@ mod tests { let workspace_b = root.join("workspace-b"); std::fs::create_dir_all(workspace_a.join(".devo")).expect("create workspace a config dir"); std::fs::create_dir_all(workspace_b.join(".devo")).expect("create workspace b config dir"); + let legacy_models_path = workspace_a.join(".devo").join("models.json"); + let legacy_models = + br#"[{"slug":"legacy-only-workspace-model","display_name":"Legacy Only"}]"#; + std::fs::write(&legacy_models_path, legacy_models).expect("write legacy workspace models"); + let absent_legacy_models_path = workspace_b.join(".devo").join("models.json"); std::fs::write( - workspace_a.join(".devo").join("models.json"), - r#"[{"slug":"workspace-a-model","display_name":"Workspace A","priority":10000}]"#, + workspace_a.join(".devo").join("config.toml"), + r#" +[model.workspace-a-model] +display_name = "Workspace A" +"#, ) - .expect("write workspace a models"); + .expect("write workspace a config"); std::fs::write( - workspace_b.join(".devo").join("models.json"), - r#"[{"slug":"workspace-b-model","display_name":"Workspace B","priority":10000}]"#, + workspace_b.join(".devo").join("config.toml"), + r#" +[model.workspace-b-model] +display_name = "Workspace B" +"#, ) - .expect("write workspace b models"); + .expect("write workspace b config"); let context_a = deps .context_for_workspace(&workspace_a) @@ -392,16 +403,103 @@ mod tests { .await .expect("load workspace b context"); - assert_eq!(context_a.default_model, "workspace-a-model"); - assert_eq!(context_b.default_model, "workspace-b-model"); assert_eq!(context_a.provider.name(), "noop"); assert_eq!(context_b.provider.name(), "noop"); + assert_eq!( + context_a + .model_catalog + .get("workspace-a-model") + .expect("workspace a model") + .display_name, + "Workspace A" + ); + assert_eq!( + context_b + .model_catalog + .get("workspace-b-model") + .expect("workspace b model") + .display_name, + "Workspace B" + ); assert!(context_a.model_catalog.get("workspace-b-model").is_none()); assert!(context_b.model_catalog.get("workspace-a-model").is_none()); + assert!( + context_a + .model_catalog + .get("legacy-only-workspace-model") + .is_none() + ); + assert!( + context_b + .model_catalog + .get("legacy-only-workspace-model") + .is_none() + ); + assert_eq!( + std::fs::read(&legacy_models_path).expect("read legacy workspace models"), + legacy_models + ); + assert!(!absent_legacy_models_path.exists()); let _ = std::fs::remove_dir_all(root); } + #[tokio::test] + async fn context_for_workspace_reuses_provider_runtime_for_model_metadata_overrides() { + let deps = test_deps( + r#" +[defaults] +model_binding = "main" + +[providers.openrouter] +enabled = true +name = "OpenRouter" +wire_apis = ["openai_chat_completions"] + +[model_bindings.main] +enabled = true +model_slug = "catalog-slug" +provider = "openrouter" +request_model = "vendor/model-name" +invocation_method = "openai_chat_completions" +"#, + ); + let workspace = unique_temp_dir("session-context-model-metadata"); + std::fs::create_dir_all(workspace.join(".devo")).expect("create workspace config dir"); + std::fs::write( + workspace.join(".devo").join("config.toml"), + r#" +[model.catalog-slug] +display_name = "Workspace Catalog Model" +"#, + ) + .expect("write workspace config"); + + let context = deps + .context_for_workspace(&workspace) + .await + .expect("load workspace context"); + + assert!(Arc::ptr_eq( + &context.provider, + &deps.process_context.provider + )); + assert!(Arc::ptr_eq( + &context.provider_router, + &deps.process_context.provider_router + )); + assert_eq!( + context + .model_catalog + .get("catalog-slug") + .expect("workspace catalog model") + .display_name, + "Workspace Catalog Model" + ); + + let _ = std::fs::remove_dir_all(workspace); + } + #[tokio::test] async fn context_for_workspace_rebuilds_provider_when_provider_http_changes() { let deps = test_deps( diff --git a/crates/server/src/session_context.rs b/crates/server/src/session_context.rs index 47e9aeed..d159a8e2 100644 --- a/crates/server/src/session_context.rs +++ b/crates/server/src/session_context.rs @@ -145,7 +145,9 @@ impl SessionRuntimeContext { .expect("inherited app config store mutex should not be poisoned") .effective_config() .clone(); - let provider_runtime_config_changed = config.provider != inherited_config.provider + let provider_runtime_config_changed = !config + .provider + .is_operationally_equivalent_to(&inherited_config.provider) || config.provider_http != inherited_config.provider_http; let registry = if !has_provider_configuration && config.mcp.servers.is_empty() { Arc::clone(&inherited_context.registry) @@ -158,8 +160,7 @@ impl SessionRuntimeContext { Arc::new(handlers::build_registry_from_plan_with_mcp(&tool_plan, mcp_manager).await) }; let model_catalog: Arc = Arc::new(PresetModelCatalog::load_from_config( - &user_config_dir, - workspace_root, + &config.provider.model_overrides, )?); let default_model = model_catalog.resolve_for_turn(None)?.slug.clone(); let (provider, provider_router, provider_default_model) = diff --git a/crates/server/tests/acp_session_contract_e2e.rs b/crates/server/tests/acp_session_contract_e2e.rs index 44d1febb..2df149ed 100644 --- a/crates/server/tests/acp_session_contract_e2e.rs +++ b/crates/server/tests/acp_session_contract_e2e.rs @@ -309,31 +309,22 @@ async fn stdio_acp_session_config_options_select_model_binding() -> Result<()> { std::fs::create_dir_all(&cwd)?; std::fs::create_dir_all(cwd.join(".devo"))?; std::fs::write( - cwd.join(".devo").join("models.json"), - serde_json::to_string(&serde_json::json!([ - { - "slug": "test-model", - "display_name": "Test Model", - "reasoning_capability": { - "levels": ["low", "medium", "high"] - }, - "default_reasoning_effort": "medium", - "base_instructions": "Test model instructions", - "supported_in_api": true - }, - { - "slug": "alt-model", - "display_name": "Alt Model", - "base_instructions": "Alt model instructions", - "supported_in_api": true - }, - { - "slug": "catalog-only-model", - "display_name": "Catalog Only Model", - "base_instructions": "Catalog-only model instructions", - "supported_in_api": true - } - ]))?, + cwd.join(".devo").join("config.toml"), + r#" +[model.test-model] +display_name = "Test Model" +reasoning_capability = { levels = ["low", "medium", "high"] } +default_reasoning_effort = "medium" +base_instructions = "Test model instructions" + +[model.alt-model] +display_name = "Alt Model" +base_instructions = "Alt model instructions" + +[model.catalog-only-model] +display_name = "Catalog Only Model" +base_instructions = "Catalog-only model instructions" +"#, )?; let cwd = cwd.to_string_lossy().into_owned(); diff --git a/crates/server/tests/cli_log_failures.rs b/crates/server/tests/cli_log_failures.rs index c05f2329..edb617b9 100644 --- a/crates/server/tests/cli_log_failures.rs +++ b/crates/server/tests/cli_log_failures.rs @@ -164,38 +164,17 @@ async fn title_generation_uses_resolved_provider_request_model() -> Result<()> { let data_root = TempDir::new()?; std::fs::create_dir_all(data_root.path().join(".devo"))?; std::fs::write( - data_root.path().join(".devo").join("models.json"), + data_root.path().join(".devo").join("config.toml"), r#" -[ - { - "slug": "catalog-title-model", - "display_name": "Catalog Title Model", - "provider": "openai_chat_completions", - "reasoning_capability": "toggle", - "reasoning_implementation": { - "model_variant": { - "variants": [ - { - "selection_value": "disabled", - "model_slug": "catalog-title-model", - "reasoning_effort": null, - "label": "Off", - "description": "Disable reasoning effort" - }, - { - "selection_value": "enabled", - "model_slug": "vendor/title-model", - "reasoning_effort": "medium", - "label": "On", - "description": "Enable reasoning effort" - } - ] - } - }, - "base_instructions": "Test title model", - "priority": 999 - } -] +[model.qwen3-coder-next] +display_name = "Catalog Title Model" +provider = "openai_chat_completions" +reasoning_capability = "toggle" +reasoning_implementation = { model_variant = { variants = [ + { selection_value = "disabled", model_slug = "qwen3-coder-next", label = "Off", description = "Disable reasoning effort" }, + { selection_value = "enabled", model_slug = "vendor/title-model", reasoning_effort = "medium", label = "On", description = "Enable reasoning effort" }, +] } } +base_instructions = "Test title model" "#, )?; diff --git a/crates/server/tests/model_config_e2e.rs b/crates/server/tests/model_config_e2e.rs index 76c91f79..381d88c1 100644 --- a/crates/server/tests/model_config_e2e.rs +++ b/crates/server/tests/model_config_e2e.rs @@ -24,31 +24,22 @@ async fn stdio_model_config_returns_cold_start_model_options_without_creating_se let cwd = home_dir.path().join("workspace"); std::fs::create_dir_all(cwd.join(".devo"))?; std::fs::write( - cwd.join(".devo").join("models.json"), - serde_json::to_string(&serde_json::json!([ - { - "slug": "test-model", - "display_name": "Test Model", - "reasoning_capability": { - "levels": ["low", "medium", "high"] - }, - "default_reasoning_effort": "medium", - "base_instructions": "Test model instructions", - "supported_in_api": true - }, - { - "slug": "alt-model", - "display_name": "Alt Model", - "base_instructions": "Alt model instructions", - "supported_in_api": true - }, - { - "slug": "catalog-only-model", - "display_name": "Catalog Only Model", - "base_instructions": "Catalog-only model instructions", - "supported_in_api": true - } - ]))?, + cwd.join(".devo").join("config.toml"), + r#" +[model.test-model] +display_name = "Test Model" +reasoning_capability = { levels = ["low", "medium", "high"] } +default_reasoning_effort = "medium" +base_instructions = "Test model instructions" + +[model.alt-model] +display_name = "Alt Model" +base_instructions = "Alt model instructions" + +[model.catalog-only-model] +display_name = "Catalog Only Model" +base_instructions = "Catalog-only model instructions" +"#, )?; let cwd = cwd.to_string_lossy().into_owned(); diff --git a/crates/server/tests/persistence_resume.rs b/crates/server/tests/persistence_resume.rs index 50e94124..0c2ce4d7 100644 --- a/crates/server/tests/persistence_resume.rs +++ b/crates/server/tests/persistence_resume.rs @@ -1534,38 +1534,17 @@ async fn configured_request_model_is_used_for_turn_metadata_and_provider_request let data_root = TempDir::new()?; std::fs::create_dir_all(data_root.path().join(".devo"))?; std::fs::write( - data_root.path().join(".devo").join("models.json"), + data_root.path().join(".devo").join("config.toml"), r#" -[ - { - "slug": "test-model", - "display_name": "test-model", - "provider": "openai_chat_completions", - "reasoning_capability": "toggle", - "reasoning_implementation": { - "model_variant": { - "variants": [ - { - "selection_value": "disabled", - "model_slug": "test-model", - "reasoning_effort": null, - "label": "Off", - "description": "Disable reasoning effort" - }, - { - "selection_value": "enabled", - "model_slug": "vendor/test-model", - "reasoning_effort": "medium", - "label": "On", - "description": "Enable reasoning effort" - } - ] - } - }, - "base_instructions": "Test model", - "priority": 999 - } -] +[model.test-model] +display_name = "test-model" +provider = "openai_chat_completions" +reasoning_capability = "toggle" +reasoning_implementation = { model_variant = { variants = [ + { selection_value = "disabled", model_slug = "test-model", label = "Off", description = "Disable reasoning effort" }, + { selection_value = "enabled", model_slug = "vendor/test-model", reasoning_effort = "medium", label = "On", description = "Enable reasoning effort" }, +] } } +base_instructions = "Test model" "#, )?; let provider = Arc::new(CapturingProvider::default()); diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 217032b0..14e21ea4 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -64,6 +64,4 @@ pub struct InteractiveTuiConfig { pub show_model_onboarding: bool, /// Whether successful onboarding should exit the TUI immediately. pub exit_after_onboarding: bool, - /// Non-fatal startup warnings to show in the transcript before user input. - pub startup_warnings: Vec, } diff --git a/crates/tui/src/interactive.rs b/crates/tui/src/interactive.rs index 4aac147b..ead8320d 100644 --- a/crates/tui/src/interactive.rs +++ b/crates/tui/src/interactive.rs @@ -29,7 +29,6 @@ use crate::chatwidget::MCP_SERVERS_TRANSCRIPT_TITLE; use crate::chatwidget::TuiSessionState; use crate::chatwidget::UserMessage; use crate::events::WorkerEvent; -use crate::history_cell; use crate::host_overlay::OverlayState; use crate::onboarding::OnboardingModelBinding; use crate::onboarding::onboarding_provider_model_binding; @@ -315,10 +314,6 @@ pub async fn run_interactive_tui(config: InteractiveTuiConfig) -> Result]` 接続レコードを選択します。 +- `request_model` はプロバイダーへ送信されるモデル id です。 +- `invocation_method` は実際に使うプロバイダープロトコルを選択します。例: [`openai_chat_completions`](https://developers.openai.com/api/reference/chat-completions/overview)、 [`openai_responses`](https://developers.openai.com/api/reference/responses/overview)、 [`anthropic_messages`](https://platform.claude.com/docs/en/api/messages)。 -## カスタムモデル - -使いたいモデルが組み込みリストにない場合は、`models.json` に追加してから -`config.toml` でバインドします。 - -ユーザーレベルのモデルカタログ: - -- macOS/Linux: `~/.devo/models.json` -- Windows: `C:\Users\yourname\.devo\models.json` - -プロジェクトレベルの上書きは `/.devo/models.json` に配置できます。 -`models.json` の `provider` は、そのモデルのデフォルト wire API メタデータです。 -実際のエンドポイントは引き続き `config.toml` の `provider` フィールドで選択されます。 -`base_instructions` を省略した場合、Devo は組み込みのデフォルト base instructions に -フォールバックします。明示的な空文字列(`""`)は、そのモデルに base instructions が -ないことを意味します。 - -`models.json` エントリの例: - -```json -[ - { - "slug": "my-coding-model", - "display_name": "My Coding Model", - "channel": "Custom", - "provider": "openai_chat_completions", - "description": "Custom OpenAI-compatible coding model.", - "reasoning_capability": "unsupported", - "context_window": 200000, - "effective_context_window_percent": 95, - "max_tokens": 4096, - "input_modalities": ["text"], - "base_instructions": "You are Devo, a coding agent. Help the user edit and understand code." - } -] +モデルメタデータにも `provider` フィールドがあり、モデルが期待する wire API を +表します。binding の `invocation_method` は実行時の接続方法を選ぶため、両者を一致 +させてください。API key は引き続き `auth.json` に保存し、provider の `credential` +参照で接続します。 + +## モデルメタデータとカスタムモデル + +ユーザーまたは workspace の `config.toml` の `[model.]` で設定します。 +組み込み slug は部分上書きで、省略したフィールドは組み込み値を保持します。新しい +slug は安全なデフォルトを持つカスタムモデルを作成し、`[providers.]` と +`[model_bindings.]` の両方で接続します。 + +組み込みモデルの部分上書き例: + +```toml +[model.qwen3-coder-next] +context_window = 262144 +effective_context_window_percent = 90 ``` -次に、その `slug` を model binding から参照します: +有効なコンテキストウィンドウの正確な式は +`context_window * effective_context_window_percent / 100` です。その結果がモデルで +利用可能なコンテキストであり、自動 compaction の境界でもあります。完全な例: ```toml +[defaults] +model_binding = "my-coding-model-example" + +[model.my-coding-model] +display_name = "My Coding Model" +description = "Custom OpenAI-compatible coding model." +channel = "Custom" +provider = "openai_chat_completions" +context_window = 200000 +effective_context_window_percent = 95 +max_tokens = 4096 +temperature = 0.2 +top_p = 0.9 +top_k = 40.0 +reasoning_capability = { levels = ["low", "medium", "high"] } +reasoning_implementation = "request_parameter" +default_reasoning_effort = "medium" +base_instructions = "You are Devo, a coding agent." +input_modalities = ["text", "image"] +truncation_policy = { mode = "tokens", limit = 12000 } +supports_image_detail_original = true + +[providers.my-provider] +enabled = true +name = "My Provider" +base_url = "https://api.example.com/v1" +credential = "my_provider_api_key" +wire_apis = ["openai_chat_completions"] + [model_bindings.my-coding-model-example] enabled = true model_slug = "my-coding-model" -provider = "my.provider" +provider = "my-provider" request_model = "provider-specific-model-name" display_name = "My Coding Model" invocation_method = "openai_chat_completions" ``` + +設定可能なメタデータには、picker に表示するモデル名 `display_name`、ユーザー向け +説明文 `description`、モデルのグループラベル `channel` があります。 +`context_window` と `effective_context_window_percent` は有効なコンテキストを決め、 +`max_tokens` は既定の response output 上限です。sampling の既定値では、 +`temperature` がランダム性、`top_p` が nucleus probability mass、`top_k` が候補 token +数の上限を制御します。`provider` wire API は `openai_chat_completions`、 +`openai_responses`、`anthropic_messages` のいずれかです。 +reasoning メタデータは型付きです。`reasoning_capability` は `unsupported`、 +`toggle`、`{ levels = [...] }`、`{ togglewithlevels = [...] }`、 +`reasoning_implementation` は `disabled`、`request_parameter`、または型付き +`model_variant` table です。model variant は同じモデルの request parameter を変える +代わりに、論理 reasoning selection を別の provider-facing model id、任意の effective +effort、任意の extra request body に対応付けます。`default_reasoning_effort` は既定の +effort を表します。`input_modalities` は `text` と `image`、`truncation_policy` は +大きすぎる tool result を model request に含める前に切り詰める byte または token +上限、`supports_image_detail_original` は original image detail を制御します。 + +`base_instructions` を省略すると、組み込みモデルは組み込み値を保持し、カスタム +モデルは Devo のデフォルトを使います。明示的な空文字列 +(`base_instructions = ""`)は base instructions なしを意味します。 + +従来の `model = "slug"` scalar は引き続き読み取れます。ただし +`[model.]` がトップレベルの `model` table namespace を使うため、新しい設定は +`[defaults].model_binding` で有効な接続を選択してください。 + +### `models.json` からの移行 + +古い `~/.devo/models.json` と `/.devo/models.json` は無視されます。 +必要なフィールドをユーザーまたは workspace の `config.toml` の +`[model.]` に手動でコピーし、対応する provider と model binding を追加または +保持してください。API key は `auth.json` に置き、`[providers.].credential` から +参照します。 diff --git a/docs/configuration.md b/docs/configuration.md index 9825226f..d47fd88d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -39,81 +39,113 @@ default_reasoning_effort = "high" The important separation is: -- `model_slug` selects Devo's local model metadata from `models.json`. -- `provider` selects the configured connection record. -- `request_model` is the provider-specific model string sent on the wire. -- `invocation_method` selects the provider protocol, such as +- `model_slug` selects Devo's local model metadata by slug. +- The binding's `provider` selects a `[providers.]` connection record. +- `request_model` is the provider-facing model id sent on the wire. +- `invocation_method` selects the operational provider protocol, such as [`openai_chat_completions`](https://developers.openai.com/api/reference/chat-completions/overview), [`openai_responses`](https://developers.openai.com/api/reference/responses/overview), or [`anthropic_messages`](https://platform.claude.com/docs/en/api/messages). +Model metadata also has a `provider` field. It describes the wire API the model +expects, while the binding's `invocation_method` chooses the connection used at +runtime; keep those values aligned. API keys remain in `auth.json` and are +connected through the provider's `credential` reference. + Existing configuration using `model_name` remains readable. Devo writes the field as `request_model` the next time that binding is saved. -## Custom Models - -If the model you want to use is not in the built-in list, add it to -`models.json`, then bind it through `config.toml`. - -User-level model catalog: - -- macOS/Linux: `~/.devo/models.json` -- Windows: `C:\Users\yourname\.devo\models.json` +## Model Metadata and Custom Models -Project-level overrides can also be placed at `/.devo/models.json`. -Catalog precedence is `/.devo/models.json`, then -`/models.json`, then the built-in catalog. +Configure model metadata in user or workspace `config.toml` under +`[model.]`. A section for a built-in slug is a partial override: omitted +fields retain their built-in values. A new slug creates a custom model with safe +defaults, which should then be connected through both `[providers.]` and +`[model_bindings.]`. -Devo keeps untouched built-in entries in the user-level catalog synchronized -with the catalog bundled in the running binary. Managed entries contain a -reserved `_devo` object with a content fingerprint. Editing any model field -turns that entry into an effective user override, so later built-in changes do -not replace it. To pin an otherwise unchanged entry explicitly, set: +For example, this changes only the built-in context window: -```json -"_devo": { - "update_policy": "pinned" -} -``` - -Custom slugs are never managed, and project-level catalogs are never rewritten. -Removing the pin and deleting the customized entry lets Devo add the current -built-in entry again on the next startup. - -In `models.json`, `provider` is the default wire API metadata for the model; the -actual endpoint is still selected by the `provider` field in `config.toml`. -If `base_instructions` is omitted, Devo falls back to the built-in default base -instructions. An explicit empty string (`""`) means the model has no base -instructions. - -Example `models.json` entry: - -```json -[ - { - "slug": "my-coding-model", - "display_name": "My Coding Model", - "channel": "Custom", - "provider": "openai_chat_completions", - "description": "Custom OpenAI-compatible coding model.", - "reasoning_capability": "unsupported", - "context_window": 200000, - "effective_context_window_percent": 95, - "max_tokens": 4096, - "input_modalities": ["text"], - "base_instructions": "You are Devo, a coding agent. Help the user edit and understand code." - } -] +```toml +[model.qwen3-coder-next] +context_window = 262144 +effective_context_window_percent = 90 ``` -Then reference that `slug` from a model binding: +The exact effective context formula is +`context_window * effective_context_window_percent / 100`; the result is the +context available to the model and the automatic-compaction boundary. For a +custom model and connection: ```toml +[defaults] +model_binding = "my-coding-model-example" + +[model.my-coding-model] +display_name = "My Coding Model" +description = "Custom OpenAI-compatible coding model." +channel = "Custom" +provider = "openai_chat_completions" +context_window = 200000 +effective_context_window_percent = 95 +max_tokens = 4096 +temperature = 0.2 +top_p = 0.9 +top_k = 40.0 +reasoning_capability = { levels = ["low", "medium", "high"] } +reasoning_implementation = "request_parameter" +default_reasoning_effort = "medium" +base_instructions = "You are Devo, a coding agent. Help the user edit and understand code." +input_modalities = ["text", "image"] +truncation_policy = { mode = "tokens", limit = 12000 } +supports_image_detail_original = true + +[providers.my-provider] +enabled = true +name = "My Provider" +base_url = "https://api.example.com/v1" +credential = "my_provider_api_key" +wire_apis = ["openai_chat_completions"] + [model_bindings.my-coding-model-example] enabled = true model_slug = "my-coding-model" -provider = "my.provider" +provider = "my-provider" request_model = "provider-specific-model-name" display_name = "My Coding Model" invocation_method = "openai_chat_completions" ``` + +Configurable metadata includes `display_name`, the picker-facing model name; +`description`, explanatory text shown to users; and `channel`, the grouping +label used to organize models. `context_window` and +`effective_context_window_percent` determine effective context, while +`max_tokens` is the default response-output limit. Sampling defaults are +`temperature` for randomness, `top_p` for nucleus probability mass, and `top_k` +for the candidate-token cap. The `provider` wire API is one of +`openai_chat_completions`, `openai_responses`, or `anthropic_messages`. +Reasoning metadata is typed: `reasoning_capability` can be `unsupported`, +`toggle`, `{ levels = [...] }`, or `{ togglewithlevels = [...] }`; +`reasoning_implementation` can be `disabled`, `request_parameter`, or a typed +`model_variant` table. A model variant maps a logical reasoning selection to a +different provider-facing model id, optional effective effort, and optional +extra request body instead of changing a parameter on the same model; +`default_reasoning_effort` selects the default typed effort. `input_modalities` +accepts `text` and `image`; `truncation_policy` chooses a byte or token limit for +oversized tool-result content before it is included in a model request; and +`supports_image_detail_original` enables original image detail. + +Omitting `base_instructions` retains built-in instructions for a built-in model +or uses Devo's default instructions for a custom model. An explicit empty string +(`base_instructions = ""`) means no base instructions. + +Legacy `model = "slug"` remains readable. Because `[model.]` now owns the +top-level `model` table namespace, new configuration must select the active +connection with `[defaults].model_binding` instead of the legacy scalar key. + +### Migrating from `models.json` + +Old `~/.devo/models.json` and `/.devo/models.json` files are ignored. +Manually copy the fields you still want into `[model.]` sections in the +user or workspace `config.toml`, then add or retain the matching provider and +model binding. Keep API keys in `auth.json`; refer to them from +`[providers.].credential`. diff --git a/docs/configuration.ru.md b/docs/configuration.ru.md index 9f031050..28775b83 100644 --- a/docs/configuration.ru.md +++ b/docs/configuration.ru.md @@ -40,59 +40,108 @@ default_reasoning_effort = "high" Важное разделение: -- `model_slug` выбирает локальные метаданные модели Devo из `models.json`. -- `provider` выбирает настроенную запись подключения. -- `request_model` - строка модели, специфичная для поставщика и отправляемая по wire. -- `invocation_method` выбирает протокол поставщика, например +- `model_slug` выбирает локальные метаданные модели Devo по slug. +- `provider` в binding выбирает запись подключения `[providers.]`. +- `request_model` - id модели, отправляемый поставщику. +- `invocation_method` выбирает рабочий протокол поставщика, например [`openai_chat_completions`](https://developers.openai.com/api/reference/chat-completions/overview), [`openai_responses`](https://developers.openai.com/api/reference/responses/overview) или [`anthropic_messages`](https://platform.claude.com/docs/en/api/messages). -## Пользовательские модели - -Если нужной модели нет во встроенном списке, добавьте ее в `models.json`, затем -привяжите через `config.toml`. - -Пользовательский каталог моделей: - -- macOS/Linux: `~/.devo/models.json` -- Windows: `C:\Users\yourname\.devo\models.json` - -Переопределения уровня проекта также можно поместить в -`/.devo/models.json`. В `models.json` поле `provider` является -метаданными wire API по умолчанию для модели; фактический endpoint по-прежнему -выбирается полем `provider` в `config.toml`. -Если `base_instructions` опущено, Devo использует встроенные base instructions по -умолчанию. Явная пустая строка (`""`) означает, что у модели нет base instructions. - -Пример записи `models.json`: - -```json -[ - { - "slug": "my-coding-model", - "display_name": "My Coding Model", - "channel": "Custom", - "provider": "openai_chat_completions", - "description": "Custom OpenAI-compatible coding model.", - "reasoning_capability": "unsupported", - "context_window": 200000, - "effective_context_window_percent": 95, - "max_tokens": 4096, - "input_modalities": ["text"], - "base_instructions": "You are Devo, a coding agent. Help the user edit and understand code." - } -] +В метаданных модели тоже есть поле `provider`: оно описывает wire API модели. +`invocation_method` в binding выбирает рабочее подключение; эти значения должны +соответствовать друг другу. API key остается в `auth.json` и подключается через +ссылку `credential` поставщика. + +## Метаданные и пользовательские модели + +Настройте метаданные в пользовательском или workspace `config.toml` в разделе +`[model.]`. Для встроенного slug это частичное переопределение: пропущенные +поля сохраняют встроенные значения. Новый slug создает модель с безопасными +значениями по умолчанию; подключите ее через `[providers.]` и +`[model_bindings.]`. + +Пример частичного переопределения встроенной модели: + +```toml +[model.qwen3-coder-next] +context_window = 262144 +effective_context_window_percent = 90 ``` -Затем сошлитесь на этот `slug` из model binding: +Точная формула эффективного контекстного окна: +`context_window * effective_context_window_percent / 100`; результат является +доступным модели контекстом и границей автоматической compaction. Полный пример: ```toml +[defaults] +model_binding = "my-coding-model-example" + +[model.my-coding-model] +display_name = "My Coding Model" +description = "Custom OpenAI-compatible coding model." +channel = "Custom" +provider = "openai_chat_completions" +context_window = 200000 +effective_context_window_percent = 95 +max_tokens = 4096 +temperature = 0.2 +top_p = 0.9 +top_k = 40.0 +reasoning_capability = { levels = ["low", "medium", "high"] } +reasoning_implementation = "request_parameter" +default_reasoning_effort = "medium" +base_instructions = "You are Devo, a coding agent." +input_modalities = ["text", "image"] +truncation_policy = { mode = "tokens", limit = 12000 } +supports_image_detail_original = true + +[providers.my-provider] +enabled = true +name = "My Provider" +base_url = "https://api.example.com/v1" +credential = "my_provider_api_key" +wire_apis = ["openai_chat_completions"] + [model_bindings.my-coding-model-example] enabled = true model_slug = "my-coding-model" -provider = "my.provider" +provider = "my-provider" request_model = "provider-specific-model-name" display_name = "My Coding Model" invocation_method = "openai_chat_completions" ``` + +Настраиваемые поля: `display_name` - имя модели в picker, `description` - пояснение +для пользователя, `channel` - метка группировки моделей. `context_window` и +`effective_context_window_percent` определяют эффективный контекст, а `max_tokens` +является лимитом response output по умолчанию. Среди sampling-настроек +`temperature` управляет случайностью, `top_p` - nucleus probability mass, а +`top_k` - числом token-кандидатов. Wire API `provider` принимает +`openai_chat_completions`, `openai_responses` или `anthropic_messages`. +Reasoning-метаданные типизированы: +`reasoning_capability` может быть `unsupported`, `toggle`, `{ levels = [...] }` +или `{ togglewithlevels = [...] }`; `reasoning_implementation` - `disabled`, +`request_parameter` или типизированная таблица `model_variant`. Model variant +сопоставляет логический reasoning selection с другим provider-facing model id, +необязательным effective effort и extra request body вместо изменения параметра +того же model; `default_reasoning_effort` задает effort по умолчанию. +`input_modalities` принимает `text` и `image`, `truncation_policy` выбирает лимит +bytes или tokens для усечения слишком большого tool result перед включением в +model request, а `supports_image_detail_original` включает исходную детализацию. + +Если `base_instructions` пропущено, встроенная модель сохраняет встроенное +значение, а пользовательская использует инструкции Devo по умолчанию. Явная +пустая строка (`base_instructions = ""`) означает отсутствие base instructions. + +Старый scalar `model = "slug"` по-прежнему читается. Но `[model.]` теперь +занимает namespace таблицы верхнего уровня `model`, поэтому новая конфигурация +должна выбирать подключение через `[defaults].model_binding`. + +### Переход с `models.json` + +Старые `~/.devo/models.json` и `/.devo/models.json` игнорируются. +Вручную скопируйте нужные поля в `[model.]` пользовательского или workspace +`config.toml`, затем добавьте или сохраните соответствующие provider и model +binding. API key храните в `auth.json` и ссылайтесь на него через +`[providers.].credential`. diff --git a/docs/configuration.zh-Hans.md b/docs/configuration.zh-Hans.md index b7c23394..a8649cea 100644 --- a/docs/configuration.zh-Hans.md +++ b/docs/configuration.zh-Hans.md @@ -38,58 +38,100 @@ default_reasoning_effort = "high" 关键区分如下: -- `model_slug` 从 `models.json` 中选择 Devo 的本地模型元数据。 -- `provider` 选择已配置的连接记录。 -- `request_model` 是发送到 provider 的特定模型字符串。 -- `invocation_method` 选择 provider 协议,例如 +- `model_slug` 按 slug 选择 Devo 的本地模型元数据。 +- binding 的 `provider` 选择一个 `[providers.]` 连接记录。 +- `request_model` 是发送到 provider 的模型 id。 +- `invocation_method` 选择实际使用的 provider 协议,例如 [`openai_chat_completions`](https://developers.openai.com/api/reference/chat-completions/overview)、 [`openai_responses`](https://developers.openai.com/api/reference/responses/overview), 或 [`anthropic_messages`](https://platform.claude.com/docs/en/api/messages)。 -## 自定义模型 - -如果想使用的模型不在内置列表中,请将它添加到 `models.json`,然后通过 -`config.toml` 绑定。 - -用户级模型目录: - -- macOS/Linux: `~/.devo/models.json` -- Windows: `C:\Users\yourname\.devo\models.json` - -项目级覆盖也可以放在 `/.devo/models.json`。 -在 `models.json` 中,`provider` 是该模型的默认 wire API 元数据;实际端点仍由 -`config.toml` 中的 `provider` 字段选择。 -若省略 `base_instructions`,Devo 会回退到内置默认 base instructions;显式写空字符串 -(`""`)表示该模型不使用 base instructions。 - -示例 `models.json` 条目: - -```json -[ - { - "slug": "my-coding-model", - "display_name": "My Coding Model", - "channel": "Custom", - "provider": "openai_chat_completions", - "description": "Custom OpenAI-compatible coding model.", - "thinking_capability": "unsupported", - "context_window": 200000, - "effective_context_window_percent": 95, - "max_tokens": 4096, - "input_modalities": ["text"], - "base_instructions": "You are Devo, a coding agent. Help the user edit and understand code." - } -] +模型元数据也有 `provider` 字段,它描述模型所需的 wire API;binding 的 +`invocation_method` 则选择运行时连接,二者应保持一致。API key 仍保存在 +`auth.json` 中,并通过 provider 的 `credential` 引用连接。 + +## 模型元数据与自定义模型 + +在用户或工作区 `config.toml` 的 `[model.]` 下配置模型元数据。内置 slug +使用部分覆盖,未写字段保留内置值;新 slug 会创建带安全默认值的自定义模型, +并应通过 `[providers.]` 和 `[model_bindings.]` 连接。 + +内置模型部分覆盖示例: + +```toml +[model.qwen3-coder-next] +context_window = 262144 +effective_context_window_percent = 90 ``` -然后从 model binding 中引用该 `slug`: +有效上下文窗口的精确公式是 +`context_window * effective_context_window_percent / 100`;结果既是模型可用上下文, +也是自动压缩边界。完整自定义示例: ```toml +[defaults] +model_binding = "my-coding-model-example" + +[model.my-coding-model] +display_name = "My Coding Model" +description = "Custom OpenAI-compatible coding model." +channel = "Custom" +provider = "openai_chat_completions" +context_window = 200000 +effective_context_window_percent = 95 +max_tokens = 4096 +temperature = 0.2 +top_p = 0.9 +top_k = 40.0 +reasoning_capability = { levels = ["low", "medium", "high"] } +reasoning_implementation = "request_parameter" +default_reasoning_effort = "medium" +base_instructions = "You are Devo, a coding agent." +input_modalities = ["text", "image"] +truncation_policy = { mode = "tokens", limit = 12000 } +supports_image_detail_original = true + +[providers.my-provider] +enabled = true +name = "My Provider" +base_url = "https://api.example.com/v1" +credential = "my_provider_api_key" +wire_apis = ["openai_chat_completions"] + [model_bindings.my-coding-model-example] enabled = true model_slug = "my-coding-model" -provider = "my.provider" +provider = "my-provider" request_model = "provider-specific-model-name" display_name = "My Coding Model" invocation_method = "openai_chat_completions" ``` + +可配置元数据包括:`display_name` 是 picker 中显示的模型名,`description` 是面向 +用户的说明文字,`channel` 是模型分组标签;`context_window` 和 +`effective_context_window_percent` 决定有效上下文,`max_tokens` 是默认响应输出上限。 +采样默认值中,`temperature` 控制随机性,`top_p` 控制 nucleus 概率质量,`top_k` +限制候选 token 数量。`provider` wire API 可为 `openai_chat_completions`、 +`openai_responses` 或 `anthropic_messages`。推理元数据是类型化的: +`reasoning_capability` 可为 +`unsupported`、`toggle`、`{ levels = [...] }` 或 +`{ togglewithlevels = [...] }`;`reasoning_implementation` 可为 `disabled`、 +`request_parameter` 或类型化 `model_variant` 表。model variant 将逻辑推理选择映射到 +不同的 provider-facing model id、可选有效 effort 和可选额外 request body,而不是修改 +同一模型的请求参数;`default_reasoning_effort` 选择默认推理强度。 +`input_modalities` 支持 `text` 和 `image`;`truncation_policy` 选择 byte 或 token +上限,在超大 tool result 进入模型请求前将其截断; +`supports_image_detail_original` 启用原始图像细节。 + +省略 `base_instructions` 时,内置模型保留内置 instructions,自定义模型使用 Devo +默认 instructions;显式空字符串(`base_instructions = ""`)表示不使用 base instructions。 + +旧的 `model = "slug"` 标量仍可读取。但 `[model.]` 现在占用顶层 `model` +表命名空间,因此新配置必须通过 `[defaults].model_binding` 选择活动连接。 + +### 从 `models.json` 迁移 + +旧的 `~/.devo/models.json` 和 `/.devo/models.json` 会被忽略。 +请手动把仍需使用的字段复制到用户或工作区 `config.toml` 的 `[model.]` +段,并添加或保留对应 provider 和 model binding。API key 继续放在 `auth.json`, +通过 `[providers.].credential` 引用。 diff --git a/docs/configuration.zh-Hant.md b/docs/configuration.zh-Hant.md index a5d1e1aa..9ef2108c 100644 --- a/docs/configuration.zh-Hant.md +++ b/docs/configuration.zh-Hant.md @@ -38,58 +38,100 @@ default_reasoning_effort = "high" 關鍵區分如下: -- `model_slug` 從 `models.json` 中選擇 Devo 的本地模型中繼資料。 -- `provider` 選擇已配置的連線記錄。 -- `request_model` 是傳送到 provider 的特定模型字串。 -- `invocation_method` 選擇 provider 協議,例如 +- `model_slug` 按 slug 選擇 Devo 的本地模型中繼資料。 +- binding 的 `provider` 選擇一個 `[providers.]` 連線記錄。 +- `request_model` 是傳送到 provider 的模型 id。 +- `invocation_method` 選擇實際使用的 provider 協議,例如 [`openai_chat_completions`](https://developers.openai.com/api/reference/chat-completions/overview)、 [`openai_responses`](https://developers.openai.com/api/reference/responses/overview), 或 [`anthropic_messages`](https://platform.claude.com/docs/en/api/messages)。 -## 自訂模型 - -如果想使用的模型不在內建清單中,請將它加入 `models.json`,然後透過 -`config.toml` 綁定。 - -使用者級模型目錄: - -- macOS/Linux: `~/.devo/models.json` -- Windows: `C:\Users\yourname\.devo\models.json` - -專案級覆蓋也可以放在 `/.devo/models.json`。 -在 `models.json` 中,`provider` 是該模型的預設 wire API 中繼資料;實際端點仍由 -`config.toml` 中的 `provider` 欄位選擇。 -若省略 `base_instructions`,Devo 會回退到內建預設 base instructions;明確寫空字串 -(`""`)表示該模型不使用 base instructions。 - -範例 `models.json` 條目: - -```json -[ - { - "slug": "my-coding-model", - "display_name": "My Coding Model", - "channel": "Custom", - "provider": "openai_chat_completions", - "description": "Custom OpenAI-compatible coding model.", - "thinking_capability": "unsupported", - "context_window": 200000, - "effective_context_window_percent": 95, - "max_tokens": 4096, - "input_modalities": ["text"], - "base_instructions": "You are Devo, a coding agent. Help the user edit and understand code." - } -] +模型中繼資料也有 `provider` 欄位,它描述模型所需的 wire API;binding 的 +`invocation_method` 則選擇執行階段連線,兩者應保持一致。API key 仍保存在 +`auth.json` 中,並透過 provider 的 `credential` 參照連線。 + +## 模型中繼資料與自訂模型 + +在使用者或工作區 `config.toml` 的 `[model.]` 下設定模型中繼資料。內建 slug +使用部分覆蓋,未寫欄位保留內建值;新 slug 會建立帶安全預設值的自訂模型, +並應透過 `[providers.]` 和 `[model_bindings.]` 連線。 + +內建模型部分覆蓋範例: + +```toml +[model.qwen3-coder-next] +context_window = 262144 +effective_context_window_percent = 90 ``` -然後從 model binding 中引用該 `slug`: +有效上下文視窗的精確公式是 +`context_window * effective_context_window_percent / 100`;結果既是模型可用上下文, +也是自動壓縮邊界。完整自訂範例: ```toml +[defaults] +model_binding = "my-coding-model-example" + +[model.my-coding-model] +display_name = "My Coding Model" +description = "Custom OpenAI-compatible coding model." +channel = "Custom" +provider = "openai_chat_completions" +context_window = 200000 +effective_context_window_percent = 95 +max_tokens = 4096 +temperature = 0.2 +top_p = 0.9 +top_k = 40.0 +reasoning_capability = { levels = ["low", "medium", "high"] } +reasoning_implementation = "request_parameter" +default_reasoning_effort = "medium" +base_instructions = "You are Devo, a coding agent." +input_modalities = ["text", "image"] +truncation_policy = { mode = "tokens", limit = 12000 } +supports_image_detail_original = true + +[providers.my-provider] +enabled = true +name = "My Provider" +base_url = "https://api.example.com/v1" +credential = "my_provider_api_key" +wire_apis = ["openai_chat_completions"] + [model_bindings.my-coding-model-example] enabled = true model_slug = "my-coding-model" -provider = "my.provider" +provider = "my-provider" request_model = "provider-specific-model-name" display_name = "My Coding Model" invocation_method = "openai_chat_completions" ``` + +可設定中繼資料包括:`display_name` 是 picker 中顯示的模型名稱,`description` 是 +面向使用者的說明文字,`channel` 是模型分組標籤;`context_window` 和 +`effective_context_window_percent` 決定有效上下文,`max_tokens` 是預設回應輸出上限。 +取樣預設值中,`temperature` 控制隨機性,`top_p` 控制 nucleus 機率質量,`top_k` +限制候選 token 數量。`provider` wire API 可為 `openai_chat_completions`、 +`openai_responses` 或 `anthropic_messages`。推理中繼資料是型別化的: +`reasoning_capability` 可為 +`unsupported`、`toggle`、`{ levels = [...] }` 或 +`{ togglewithlevels = [...] }`;`reasoning_implementation` 可為 `disabled`、 +`request_parameter` 或型別化 `model_variant` 表。model variant 將邏輯推理選擇映射到 +不同的 provider-facing model id、可選有效 effort 和可選額外 request body,而不是修改 +同一模型的請求參數;`default_reasoning_effort` 選擇預設推理強度。 +`input_modalities` 支援 `text` 和 `image`;`truncation_policy` 選擇 byte 或 token +上限,在超大 tool result 進入模型請求前將其截斷; +`supports_image_detail_original` 啟用原始影像細節。 + +省略 `base_instructions` 時,內建模型保留內建 instructions,自訂模型使用 Devo +預設 instructions;明確空字串(`base_instructions = ""`)表示不使用 base instructions。 + +舊的 `model = "slug"` 純量仍可讀取。但 `[model.]` 現在占用頂層 `model` +表命名空間,因此新設定必須透過 `[defaults].model_binding` 選擇作用中連線。 + +### 從 `models.json` 遷移 + +舊的 `~/.devo/models.json` 和 `/.devo/models.json` 會被忽略。 +請手動把仍需使用的欄位複製到使用者或工作區 `config.toml` 的 `[model.]` +段,並新增或保留對應 provider 和 model binding。API key 繼續放在 `auth.json`, +透過 `[providers.].credential` 參照。 diff --git a/specs/L2/app/L2-DES-APP-005-config-toml-schema.md b/specs/L2/app/L2-DES-APP-005-config-toml-schema.md index 84b065f5..824b9e21 100644 --- a/specs/L2/app/L2-DES-APP-005-config-toml-schema.md +++ b/specs/L2/app/L2-DES-APP-005-config-toml-schema.md @@ -54,6 +54,7 @@ Configuration should use a single TOML schema version with stable, keyed records Keyed TOML tables are preferred for mergeable records because workspace-scoped configuration can override fields, disable records, or add records by stable identifier: - `[providers.]` +- `[model.]` - `[model_bindings.]` - `[mcp.servers.]` - `[skills.roots.]` @@ -78,6 +79,7 @@ Top-level sections: [defaults] [provider_http] [providers.] +[model.] [model_bindings.] [tools.web_search] [mcp.servers.] @@ -122,6 +124,9 @@ name = "OpenAI" base_url = "https://api.openai.com/v1" credential = "openai_api_key" +[model."openai/gpt-5.5"] +effective_context_window_percent = 90 + [model_bindings.gpt55-openrouter] enabled = true model_slug = "openai/gpt-5.5" @@ -382,6 +387,31 @@ Auth records: Credential ids are stable keys inside `auth.json`. Renaming a provider does not rename its credential id. +## Model Metadata + +`[model.]` stores partial metadata overrides for embedded models and +metadata for custom model slugs. The embedded catalog is the only model catalog +base. Effective metadata is produced by overlaying user-scoped and then +workspace-scoped `[model.]` fields; an omitted field preserves the +lower-priority or embedded value. + +Supported fields include `display_name`, `description`, `channel`, +`context_window`, `effective_context_window_percent`, `max_tokens`, +`temperature`, `top_p`, `top_k`, `provider`, `reasoning_capability`, +`reasoning_implementation`, `default_reasoning_effort`, `base_instructions`, +`input_modalities`, `truncation_policy`, and +`supports_image_detail_original`. A new slug creates a custom model definition +with safe defaults; invocability still requires a matching provider and model +binding. + +Legacy top-level `model = "slug"` remains readable, but new configuration must +select through `[defaults].model_binding` because `[model.]` owns the +top-level `model` table namespace. Old user `~/.devo/models.json` and workspace +`/.devo/models.json` files are ignored. Migration is manual: copy +desired metadata fields into the corresponding user or workspace +`config.toml` `[model.]` section, then retain or add the required provider +and binding records. Credential values remain in user-scoped `auth.json`. + ## Model Bindings `[model_bindings.]` stores configured invocable models. @@ -410,7 +440,7 @@ Allowed `invocation_method` values for the initial schema: Rules: -- `model_slug` must exist in the effective model catalog, whose precedence is workspace `models.json`, user `models.json`, then built-in defaults. +- `model_slug` must exist in the effective model catalog produced from embedded definitions plus the field-wise merged user and workspace `[model.]` configuration. - `display_name` is display metadata only. It must not be used as a stable identifier, provider API model name, or cross-reference key. - Program-created model bindings must persist `display_name`. When the user accepts the default suggestion, that persisted value should be copied from the built-in supported model definition's display name. - `provider` must reference an enabled effective provider. diff --git a/specs/L2/model/L2-DES-MODEL-001-model-provider-binding.md b/specs/L2/model/L2-DES-MODEL-001-model-provider-binding.md index f2ed4307..da86920e 100644 --- a/specs/L2/model/L2-DES-MODEL-001-model-provider-binding.md +++ b/specs/L2/model/L2-DES-MODEL-001-model-provider-binding.md @@ -23,6 +23,8 @@ The same supported model may be exposed by different providers under different m User providers and model-provider bindings are persisted through application configuration. Effective values are resolved from user-scoped and workspace-scoped `config.toml` files. Workspace fields override user fields only when the same field is present; user-only fields remain effective. +The effective supported-model catalog starts from model definitions embedded in the program. User-scoped and workspace-scoped `[model.]` sections then overlay metadata field by field, with workspace fields taking precedence over overlapping user fields. A section for an embedded slug is a partial override; a new slug adds a custom supported model definition with safe defaults. + The concrete `config.toml` field shape for persisted providers, model bindings, and defaults is defined by `L2-DES-APP-005`. Credential material is stored only in user-scoped `auth.json` and referenced from provider records by credential id. ## Source Requirements @@ -113,7 +115,7 @@ Legacy configuration may provide `model_name` as a read alias for `request_model The binding must be valid only when: -- The supported model slug exists in the effective model catalog, whose precedence is workspace `models.json`, user `models.json`, then built-in defaults. +- The supported model slug exists in the effective model catalog produced from embedded definitions plus the field-wise merged user and workspace `[model.]` configuration. - The provider exists and has sufficient connection details. - The invocation method is supported by the program. - The reasoning effort is allowed by the selected supported model. @@ -130,11 +132,13 @@ Persistent model configuration conceptually stores: - Default selected binding where supported. - Default reasoning effort where supported. -Persistent model configuration should store references to supported model slugs rather than copying the full supported model definition into user or workspace configuration. +Persistent model bindings should store references to supported model slugs rather than copying full supported model definitions. User or workspace configuration may separately persist only the model metadata fields intentionally added or overridden under `[model.]`. Current-session model and reasoning selection is not itself a `ModelProviderBinding`. A session may select a configured binding and use a session-local reasoning effort without rewriting the binding record. Configuration writes should occur only when a provider, binding, or persisted default selection is created or changed by the relevant workflow. -The TOML schema stores global provider HTTP settings under `[provider_http]`, providers under `[providers.]`, invocable bindings under `[model_bindings.]`, and durable default selection under `[defaults]`. Provider records reference credentials by id and may contain raw custom HTTP header configuration. Runtime-only `ResolvedModelProfile` values are not persisted in `config.toml`. +The TOML schema stores model metadata additions and partial overrides under `[model.]`, global provider HTTP settings under `[provider_http]`, providers under `[providers.]`, invocable bindings under `[model_bindings.]`, and durable default selection under `[defaults]`. Provider records reference credentials by id and may contain raw custom HTTP header configuration. Runtime-only `ResolvedModelProfile` values are not persisted in `config.toml`. + +Legacy user `~/.devo/models.json` and workspace `/.devo/models.json` files are ignored. Migration is manual: users copy desired model metadata fields into the corresponding user or workspace `config.toml` `[model.]` section and retain or add the provider and binding records required for invocation. The effective provider and binding set is resolved through configuration precedence: From 6fb2476808470b5d9ba9b89609dfbe0e6d4b9e53 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Sat, 18 Jul 2026 20:18:20 +0800 Subject: [PATCH 2/8] feat: implement permission and sandbox --- Cargo.lock | 514 ++++++++- Cargo.toml | 5 + crates/config/src/app.rs | 33 +- crates/config/src/lib.rs | 2 + crates/config/src/permission.rs | 84 ++ crates/config/src/tests.rs | 285 +++++ crates/rmcp-client/src/oauth.rs | 30 +- crates/safety/Cargo.toml | 5 + crates/safety/src/lib.rs | 1 + .../src/permission/bash_command_splitting.rs | 633 +++++++++++ .../src/permission/file_access_model.rs | 353 +++++++ crates/safety/src/permission/mod.rs | 13 + crates/safety/src/permission/policy.rs | 141 +++ crates/safety/src/permission/rules.rs | 294 ++++++ crates/safety/src/permission/shell_access.rs | 502 +++++++++ crates/safety/src/permission/types.rs | 70 ++ crates/safety/tests/permission_policy.rs | 865 +++++++++++++++ crates/sandbox/Cargo.toml | 58 ++ crates/sandbox/src/bwrap.rs | 728 +++++++++++++ crates/sandbox/src/child_net.rs | 445 ++++++++ crates/sandbox/src/deny/glob.rs | 845 +++++++++++++++ crates/sandbox/src/deny/mod.rs | 311 ++++++ crates/sandbox/src/lib.rs | 396 +++++++ crates/sandbox/src/logging.rs | 124 +++ crates/sandbox/src/network_policy.rs | 503 +++++++++ crates/sandbox/src/paths.rs | 148 +++ crates/sandbox/src/profiles.rs | 981 ++++++++++++++++++ crates/sandbox/src/types.rs | 193 ++++ crates/sandbox/tests/deny_paths_e2e.rs | 451 ++++++++ crates/sandbox/tests/integration_test.rs | 107 ++ .../skills/.system/skill-creator/SKILL.md | 54 +- .../skill-creator/scripts/init_skill.py | 10 +- .../skills/.system/skill-installer/SKILL.md | 8 +- .../scripts/install-skill-from-github.py | 12 +- .../skill-installer/scripts/list-skills.py | 8 +- crates/server/src/runtime/approval.rs | 4 +- crates/server/src/runtime/handlers/session.rs | 11 +- crates/server/src/runtime/items.rs | 25 + .../src/assets/samples/skill-creator/SKILL.md | 54 +- .../skill-creator/scripts/init_skill.py | 10 +- .../assets/samples/skill-installer/SKILL.md | 8 +- .../scripts/install-skill-from-github.py | 12 +- .../skill-installer/scripts/list-skills.py | 8 +- .../tui/src/bottom_pane/approval_overlay.rs | 2 - crates/tui/src/chatwidget/configuration.rs | 2 +- .../tui/src/chatwidget/permission_presets.rs | 22 +- crates/tui/src/chatwidget_tests.rs | 9 +- crates/tui/src/text_formatting.rs | 9 +- ...top-reference-search-and-session-refill.md | 130 --- ...esktop-request-user-input-and-plan-card.md | 162 --- .../plans/2026-07-12-fast-initialize.md | 66 -- 51 files changed, 9234 insertions(+), 512 deletions(-) create mode 100644 crates/config/src/permission.rs create mode 100644 crates/safety/src/permission/bash_command_splitting.rs create mode 100644 crates/safety/src/permission/file_access_model.rs create mode 100644 crates/safety/src/permission/mod.rs create mode 100644 crates/safety/src/permission/policy.rs create mode 100644 crates/safety/src/permission/rules.rs create mode 100644 crates/safety/src/permission/shell_access.rs create mode 100644 crates/safety/src/permission/types.rs create mode 100644 crates/safety/tests/permission_policy.rs create mode 100644 crates/sandbox/Cargo.toml create mode 100644 crates/sandbox/src/bwrap.rs create mode 100644 crates/sandbox/src/child_net.rs create mode 100644 crates/sandbox/src/deny/glob.rs create mode 100644 crates/sandbox/src/deny/mod.rs create mode 100644 crates/sandbox/src/lib.rs create mode 100644 crates/sandbox/src/logging.rs create mode 100644 crates/sandbox/src/network_policy.rs create mode 100644 crates/sandbox/src/paths.rs create mode 100644 crates/sandbox/src/profiles.rs create mode 100644 crates/sandbox/src/types.rs create mode 100644 crates/sandbox/tests/deny_paths_e2e.rs create mode 100644 crates/sandbox/tests/integration_test.rs delete mode 100644 docs/superpowers/plans/2026-07-12-desktop-reference-search-and-session-refill.md delete mode 100644 docs/superpowers/plans/2026-07-12-desktop-request-user-input-and-plan-card.md delete mode 100644 docs/superpowers/plans/2026-07-12-fast-initialize.md diff --git a/Cargo.lock b/Cargo.lock index cd891573..87b64e91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -428,6 +428,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -513,6 +514,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bincode" version = "1.3.3" @@ -879,6 +886,30 @@ dependencies = [ "cc", ] +[[package]] +name = "cmpv2" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961b955a666e25ee5a1091d219128d6e6401e3dab84efb1a2bf6b4035d797b39" +dependencies = [ + "crmf", + "der", + "spki", + "x509-cert", +] + +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid 0.9.6", + "der", + "spki", + "x509-cert", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -1016,6 +1047,12 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -1124,6 +1161,18 @@ dependencies = [ "cfg-if 1.0.4", ] +[[package]] +name = "crmf" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fe21b96d5b87f5de4b5b7202ec41c00110ac817ce6728fe75fb2fe5962ed92" +dependencies = [ + "cms", + "der", + "spki", + "x509-cert", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -1358,6 +1407,30 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "der_derive", + "flagset", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1733,6 +1806,9 @@ name = "devo-safety" version = "0.1.30" dependencies = [ "async-trait", + "devo-config", + "devo-util-shell-command", + "globset", "pretty_assertions", "regex", "serde", @@ -1740,6 +1816,33 @@ dependencies = [ "smol_str 0.3.2", "thiserror 2.0.18", "tokio", + "tree-sitter", + "url", +] + +[[package]] +name = "devo-sandbox" +version = "0.1.30" +dependencies = [ + "anyhow", + "chrono", + "devo-util-paths", + "dirs", + "dunce", + "globset", + "ignore", + "libc", + "nono", + "pretty_assertions", + "regex", + "serde", + "serde_json", + "serial_test", + "sha2 0.10.9", + "thiserror 2.0.18", + "toml", + "tracing", + "url", ] [[package]] @@ -1998,7 +2101,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", ] @@ -2285,6 +2388,12 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.9" @@ -2307,6 +2416,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -2628,7 +2743,18 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -3400,6 +3526,17 @@ dependencies = [ "libc", ] +[[package]] +name = "landlock" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "635839550ae8b90d9fd2571460a6645dc0aec070225956ca7a2831ed31d2795d" +dependencies = [ + "enumflags2", + "libc", + "thiserror 2.0.18", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -3787,6 +3924,35 @@ dependencies = [ "memchr", ] +[[package]] +name = "nono" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae7eb523cc2036e9ad6527411c3da5dc2172dc454cc3447a03b910420a39bfee" +dependencies = [ + "der", + "getrandom 0.4.2", + "globset", + "ignore", + "landlock", + "libc", + "nix 0.31.3", + "prettyplease", + "regress", + "serde", + "serde_json", + "sha2 0.11.0", + "sigstore-trust-root", + "sigstore-verify", + "syn", + "thiserror 2.0.18", + "tracing", + "typify", + "walkdir", + "x509-cert", + "zeroize", +] + [[package]] name = "notify" version = "8.2.0" @@ -4186,6 +4352,25 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4782,6 +4967,16 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "regress" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158a764437582235e3501f683b93a0a6f8d825d04a789dbe5ed30b8799b8908a" +dependencies = [ + "hashbrown 0.16.1", + "memchr", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -4909,7 +5104,7 @@ dependencies = [ "cfg-if 1.0.4", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -5100,7 +5295,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -5115,6 +5310,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + [[package]] name = "safe-transmute" version = "0.11.3" @@ -5280,6 +5481,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -5336,6 +5541,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_json_canonicalizer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "serde_path_to_error" version = "0.1.20" @@ -5367,6 +5583,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_tokenstream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c49585c52c01f13c5c2ebb333f14f6885d76daa768d8a037d28017ec538c69" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -5582,6 +5810,175 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "sigstore-bundle" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7dc53c8a941858d01479622dbb7650aaa2ee0ca1fb58fb6cdddbfc3064abbb" +dependencies = [ + "base64 0.22.1", + "hex", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.18", +] + +[[package]] +name = "sigstore-crypto" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da90c3d9af638898a5fdc347479b18f950bb0dde11ecf2244f94cd28135da53b" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "const-oid 0.9.6", + "der", + "digest 0.10.7", + "pem", + "rand_core 0.10.1", + "sha2 0.10.9", + "signature", + "sigstore-types", + "spki", + "thiserror 2.0.18", + "tracing", + "x509-cert", +] + +[[package]] +name = "sigstore-merkle" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c0d53558825c77716855c13794306fff766854c1bf92948e77d7890da3f2455" +dependencies = [ + "base64 0.22.1", + "hex", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.18", +] + +[[package]] +name = "sigstore-rekor" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "641fdaaa40d0cd6e249cf1671c2240beff1a3ece578062cf43e750779e8db2b3" +dependencies = [ + "base64 0.22.1", + "hex", + "reqwest 0.13.4", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-types", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "sigstore-trust-root" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8762a4813252faffdf6f7e2040213078eb2482fbc351381134f3fc60c1be9c" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "rustls-pki-types", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.18", + "x509-cert", +] + +[[package]] +name = "sigstore-tsa" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bc86c8abc1ece6832236e7e34e9baebb5d4d40490c0332b814a0d8624ca7255" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "chrono", + "cmpv2", + "cms", + "const-oid 0.9.6", + "der", + "hex", + "rand 0.10.1", + "reqwest 0.13.4", + "rustls-pki-types", + "rustls-webpki", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.18", + "tracing", + "x509-cert", + "x509-tsp", +] + +[[package]] +name = "sigstore-types" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8d870c9bcfdf83396ac2bd2d6a23c5d09ec02cc9fda50fa1cbb3dd71a9bee53" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "pem", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "sigstore-verify" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d751d608afd334fb9d8c037ad9efef69f1954494eff99cd79a0a6fc8af34ccb" +dependencies = [ + "base64 0.22.1", + "chrono", + "cms", + "const-oid 0.9.6", + "hex", + "pem", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sigstore-bundle", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-trust-root", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.18", + "tls_codec", + "tracing", + "x509-cert", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -5657,6 +6054,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "spm_precompiled" version = "0.1.4" @@ -6059,6 +6466,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokenizers" version = "0.21.4" @@ -6788,6 +7216,53 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6" +[[package]] +name = "typify" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0b89f47309feaeb23c4509c15c9a04234f7deccef6f96c3bfe95319819a304" +dependencies = [ + "typify-impl", + "typify-macro", +] + +[[package]] +name = "typify-impl" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7b026f540b148b81043c720889dbb942b08659aa8a43f624ac4f04dbfc1861" +dependencies = [ + "heck", + "log", + "proc-macro2", + "quote", + "regress", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "syn", + "thiserror 2.0.18", + "unicode-ident", +] + +[[package]] +name = "typify-macro" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ed96c57f06ae0839416b986921a98f18b220da63bbb243a8570a00c8492183" +dependencies = [ + "proc-macro2", + "quote", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "serde_tokenstream", + "syn", + "typify-impl", +] + [[package]] name = "uds_windows" version = "1.1.0" @@ -6873,6 +7348,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -7846,6 +8327,31 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der", + "sha1", + "signature", + "spki", + "tls_codec", +] + +[[package]] +name = "x509-tsp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5ceece934a21607055b7ac5c25adb56a2ff559804b10705dc674d1d838c15e1" +dependencies = [ + "cmpv2", + "cms", + "der", +] + [[package]] name = "xdg-home" version = "1.3.0" diff --git a/Cargo.toml b/Cargo.toml index 572d92ec..95336a4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/tools", "crates/provider", "crates/safety", + "crates/sandbox", "crates/tasks", "crates/mcp", "crates/network-proxy", @@ -65,6 +66,7 @@ devo-protocol = { path = "crates/protocol" } devo-provider = { path = "crates/provider" } devo-rmcp-client = { path = "crates/rmcp-client" } devo-safety = { path = "crates/safety" } +devo-sandbox = { path = "crates/sandbox" } devo-server = { path = "crates/server" } devo-skills = { path = "crates/skills" } devo-tasks = { path = "crates/tasks" } @@ -77,8 +79,10 @@ devo-util-process = { path = "crates/utils/process" } devo-util-shell-command = { path = "crates/utils/shell-command" } diffy = "0.4.2" dirs = "6" +dunce = "1" fs2 = "0.4" futures = { version = "0.3", default-features = false } +globset = "0.4" hf-hub = { version = "1.0.0-rc.1", features = ["blocking", "rustls-tls"] } hnsw_rs = "0.3.4" iana-time-zone = "0.1.65" @@ -92,6 +96,7 @@ lazy_static = "1" libc = "0.2.182" model2vec = "0.3.0" notify = "8.2.0" +nono = { version = "=0.53.0", default-features = false } nucleo = { git = "https://github.com/helix-editor/nucleo.git", rev = "4253de9faabb4e5c6d81d946a5e35a90f87347ee" } once_cell = "1.20.2" pathdiff = "0.2" diff --git a/crates/config/src/app.rs b/crates/config/src/app.rs index 9f358f93..6d5ef64d 100644 --- a/crates/config/src/app.rs +++ b/crates/config/src/app.rs @@ -25,6 +25,7 @@ use crate::LoggingFileConfig; use crate::McpConfig; use crate::ModelBindingConfig; use crate::OAuthCredentialsStoreMode; +use crate::PermissionConfig; use crate::ProviderConfigError; use crate::ProviderConfigSection; use crate::ProviderHttpConfig; @@ -71,6 +72,9 @@ pub struct AppConfig { /// External lifecycle hooks keyed by event name. #[serde(default, skip_serializing_if = "HooksConfig::is_empty")] pub hooks: HooksConfig, + /// Configured rules and default behavior for tool permission requests. + #[serde(default)] + pub permission: PermissionConfig, /// Provider, model, and active model defaults. #[serde(flatten)] pub provider: ProviderConfigSection, @@ -162,6 +166,7 @@ impl Default for AppConfig { mcp: McpConfig::default(), tools: ToolsConfig::default(), hooks: HooksConfig::default(), + permission: PermissionConfig::default(), provider: ProviderConfigSection::default(), provider_http: ProviderHttpConfig::default(), updates: UpdatesConfig { @@ -618,7 +623,7 @@ impl AppConfigLoader for FileSystemAppConfigLoader { provider_section_from_value(&user_path, &user_config)?, &user_config, ); - merge_toml_values(&mut merged, user_config); + merge_app_config_values(&mut merged, user_config); } if let Some(workspace_root) = workspace_root { @@ -629,7 +634,7 @@ impl AppConfigLoader for FileSystemAppConfigLoader { provider_section_from_value(&project_path, &project_config)?, &project_config, ); - merge_toml_values(&mut merged, project_config); + merge_app_config_values(&mut merged, project_config); } } @@ -637,7 +642,7 @@ impl AppConfigLoader for FileSystemAppConfigLoader { provider_section_from_value(Path::new(""), &self.cli_overrides)?, &self.cli_overrides, ); - merge_toml_values_ref(&mut merged, &self.cli_overrides); + merge_app_config_values_ref(&mut merged, &self.cli_overrides); let mut config: AppConfig = merged @@ -652,6 +657,28 @@ impl AppConfigLoader for FileSystemAppConfigLoader { } } +fn merge_app_config_values(base: &mut toml::Value, overlay: toml::Value) { + replace_permission_section_if_present(base, &overlay); + merge_toml_values(base, overlay); +} + +fn merge_app_config_values_ref(base: &mut toml::Value, overlay: &toml::Value) { + replace_permission_section_if_present(base, overlay); + merge_toml_values_ref(base, overlay); +} + +/// Replaces permission configuration as a unit when a higher-priority source +/// explicitly supplies its `[permission]` section. +fn replace_permission_section_if_present(base: &mut toml::Value, overlay: &toml::Value) { + let Some(permission) = overlay.as_table().and_then(|table| table.get("permission")) else { + return; + }; + + if let Some(base_table) = base.as_table_mut() { + base_table.insert("permission".to_string(), permission.clone()); + } +} + fn merge_toml_values(base: &mut toml::Value, overlay: toml::Value) { match (base, overlay) { (toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => { diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index ab55d8eb..85b7c023 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -10,6 +10,7 @@ mod hooks; mod logging; mod mcp; mod oauth; +mod permission; mod provider; mod server; mod skills; @@ -22,6 +23,7 @@ pub use hooks::*; pub use logging::*; pub use mcp::*; pub use oauth::*; +pub use permission::*; pub use provider::*; pub use server::*; pub use skills::*; diff --git a/crates/config/src/permission.rs b/crates/config/src/permission.rs new file mode 100644 index 00000000..a35430a0 --- /dev/null +++ b/crates/config/src/permission.rs @@ -0,0 +1,84 @@ +//! Typed permission-policy configuration. + +use serde::Deserialize; +use serde::Serialize; + +/// Permission-policy configuration loaded from the `[permission]` TOML section. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct PermissionConfig { + /// Rules evaluated by the permission-policy runtime in declaration order. + pub rules: Vec, + /// Behavior when no rule or prior decision resolves a tool call. + #[serde(rename = "default_mode")] + pub prompt_policy: PromptPolicy, +} + +/// A single permission-policy rule. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PermissionRule { + /// The action taken when this rule matches. + #[serde(default)] + pub action: RuleAction, + /// The tool category that this rule applies to. + #[serde(default)] + pub tool: ToolFilter, + /// An optional glob or domain pattern for the selected tool category. + pub pattern: Option, + /// How to interpret `pattern`. + #[serde(default)] + pub pattern_mode: PatternMode, +} + +/// Selects whether a rule pattern matches a glob or a URL host. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum PatternMode { + /// Match the target with a glob pattern. + #[default] + Glob, + /// Match the URL host instead of the complete target. + Domain, +} + +/// Action to take when a permission rule matches. +/// +/// The default is deny so an omitted `action` cannot silently make a rule +/// permissive. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum RuleAction { + Allow, + #[default] + Deny, + Ask, +} + +/// Tool category used to filter a permission rule. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum ToolFilter { + /// Match every tool category. + #[default] + Any, + Bash, + Edit, + Read, + Grep, + Mcp, + WebFetch, + WebSearch, +} + +/// Default behavior for permission requests that no rule resolves. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum PromptPolicy { + /// Ask the user to approve the request. + #[default] + Ask, + /// Deny the request without prompting. + Deny, + /// Send unresolved requests through the automatic reviewer. + Auto, +} diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs index 00cf7213..6fcf90c1 100644 --- a/crates/config/src/tests.rs +++ b/crates/config/src/tests.rs @@ -22,12 +22,18 @@ use super::LoggingConfig; use super::ModelBindingConfig; use super::ModelOverrideConfig; use super::OAuthCredentialsStoreMode; +use super::PatternMode; +use super::PermissionConfig; +use super::PermissionRule; use super::ProjectConfig; +use super::PromptPolicy; use super::ProviderConfigSection; use super::ProviderDefaultsConfig; use super::ProviderHttpConfig; use super::ProviderVendorConfig; +use super::RuleAction; use super::SummaryModelSelection; +use super::ToolFilter; use super::ToolsConfig; use super::UpdatesConfig; use crate::BundledSkillsConfig; @@ -133,6 +139,7 @@ check_interval_hours = 48 mcp: super::McpConfig::default(), tools: ToolsConfig::default(), hooks: HooksConfig::default(), + permission: PermissionConfig::default(), provider: ProviderConfigSection::default(), provider_http: super::ProviderHttpConfig::default(), updates: UpdatesConfig { @@ -148,6 +155,284 @@ check_interval_hours = 48 let _ = std::fs::remove_dir_all(root); } +#[test] +fn loader_defaults_permission_config_when_section_is_absent() { + let root = unique_temp_dir("permission-default"); + let home = root.join("home").join(".devo"); + std::fs::create_dir_all(&home).expect("home config dir"); + + let config = FileSystemAppConfigLoader::new(home) + .load(None) + .expect("load config"); + + assert_eq!(config.permission, PermissionConfig::default()); + + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn loader_reads_permission_rules_and_auto_default_mode() { + let root = unique_temp_dir("permission-rules"); + let home = root.join("home").join(".devo"); + std::fs::create_dir_all(&home).expect("home config dir"); + std::fs::write( + home.join("config.toml"), + r#" +[permission] +default_mode = "auto" + +[[permission.rules]] +action = "allow" +tool = "bash" +pattern = "git *" + +[[permission.rules]] +action = "deny" +tool = "edit" +pattern = "**/.env" + +[[permission.rules]] +action = "ask" +tool = "web_fetch" +pattern = "example.com" +pattern_mode = "domain" + +[[permission.rules]] +tool = "read" +"#, + ) + .expect("write user config"); + + let config = FileSystemAppConfigLoader::new(home) + .load(None) + .expect("load config"); + + assert_eq!( + config.permission, + PermissionConfig { + rules: vec![ + PermissionRule { + action: RuleAction::Allow, + tool: ToolFilter::Bash, + pattern: Some("git *".to_string()), + pattern_mode: PatternMode::Glob, + }, + PermissionRule { + action: RuleAction::Deny, + tool: ToolFilter::Edit, + pattern: Some("**/.env".to_string()), + pattern_mode: PatternMode::Glob, + }, + PermissionRule { + action: RuleAction::Ask, + tool: ToolFilter::WebFetch, + pattern: Some("example.com".to_string()), + pattern_mode: PatternMode::Domain, + }, + PermissionRule { + action: RuleAction::Deny, + tool: ToolFilter::Read, + pattern: None, + pattern_mode: PatternMode::Glob, + }, + ], + prompt_policy: PromptPolicy::Auto, + } + ); + + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn default_app_config_serializes_permission_default_mode() { + let serialized = toml::Value::try_from(AppConfig::default()).expect("serialize config"); + + assert_eq!( + serialized + .get("permission") + .and_then(toml::Value::as_table) + .and_then(|permission| permission.get("default_mode")) + .and_then(toml::Value::as_str), + Some("ask") + ); +} + +#[test] +fn loader_rejects_invalid_permission_rule_action() { + let root = unique_temp_dir("permission-invalid-action"); + let home = root.join("home").join(".devo"); + std::fs::create_dir_all(&home).expect("home config dir"); + std::fs::write( + home.join("config.toml"), + "[[permission.rules]]\naction = 'approve'\n", + ) + .expect("write user config"); + + let result = FileSystemAppConfigLoader::new(home).load(None); + + assert!(matches!(result, Err(super::AppConfigError::Parse { .. }))); + + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn loader_preserves_lower_permission_section_when_higher_layer_omits_it() { + let root = unique_temp_dir("permission-preserve-overlay"); + let home = root.join("home").join(".devo"); + let workspace = root.join("workspace"); + std::fs::create_dir_all(&home).expect("home config dir"); + std::fs::create_dir_all(workspace.join(".devo")).expect("workspace config dir"); + std::fs::write( + home.join("config.toml"), + "[permission]\ndefault_mode = 'deny'\n[[permission.rules]]\naction = 'allow'\ntool = 'web_search'\n", + ) + .expect("write user config"); + std::fs::write( + workspace.join(".devo").join("config.toml"), + "[logging]\nlevel = 'debug'\n", + ) + .expect("write project config"); + + let config = FileSystemAppConfigLoader::new(home) + .load(Some(&workspace)) + .expect("load config"); + + assert_eq!( + config.permission, + PermissionConfig { + rules: vec![PermissionRule { + action: RuleAction::Allow, + tool: ToolFilter::WebSearch, + pattern: None, + pattern_mode: PatternMode::Glob, + }], + prompt_policy: PromptPolicy::Deny, + } + ); + + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn loader_replaces_lower_permission_section_when_higher_layer_supplies_it() { + let root = unique_temp_dir("permission-replace-overlay"); + let home = root.join("home").join(".devo"); + let workspace = root.join("workspace"); + std::fs::create_dir_all(&home).expect("home config dir"); + std::fs::create_dir_all(workspace.join(".devo")).expect("workspace config dir"); + std::fs::write( + home.join("config.toml"), + "[permission]\ndefault_mode = 'auto'\n[[permission.rules]]\naction = 'allow'\ntool = 'bash'\n", + ) + .expect("write user config"); + std::fs::write( + workspace.join(".devo").join("config.toml"), + "[permission]\ndefault_mode = 'deny'\n[[permission.rules]]\naction = 'ask'\ntool = 'mcp'\n", + ) + .expect("write project config"); + + let config = FileSystemAppConfigLoader::new(home) + .load(Some(&workspace)) + .expect("load config"); + + assert_eq!( + config.permission, + PermissionConfig { + rules: vec![PermissionRule { + action: RuleAction::Ask, + tool: ToolFilter::Mcp, + pattern: None, + pattern_mode: PatternMode::Glob, + }], + prompt_policy: PromptPolicy::Deny, + } + ); + + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn loader_cli_overlay_preserves_workspace_permission_when_omitted() { + let root = unique_temp_dir("permission-cli-preserve-overlay"); + let home = root.join("home").join(".devo"); + let workspace = root.join("workspace"); + std::fs::create_dir_all(&home).expect("home config dir"); + std::fs::create_dir_all(workspace.join(".devo")).expect("workspace config dir"); + std::fs::write( + workspace.join(".devo").join("config.toml"), + "[permission]\ndefault_mode = 'auto'\n[[permission.rules]]\naction = 'allow'\ntool = 'bash'\npattern = 'git *'\n", + ) + .expect("write project config"); + let cli_overrides: toml::Value = "[logging]\nlevel = 'trace'\n" + .parse() + .expect("parse cli overrides"); + + let config = FileSystemAppConfigLoader::new(home) + .with_cli_overrides(cli_overrides) + .load(Some(&workspace)) + .expect("load config"); + + assert_eq!( + config.permission, + PermissionConfig { + rules: vec![PermissionRule { + action: RuleAction::Allow, + tool: ToolFilter::Bash, + pattern: Some("git *".to_string()), + pattern_mode: PatternMode::Glob, + }], + prompt_policy: PromptPolicy::Auto, + } + ); + + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn loader_cli_overlay_replaces_workspace_permission_section() { + let root = unique_temp_dir("permission-cli-replace-overlay"); + let home = root.join("home").join(".devo"); + let workspace = root.join("workspace"); + std::fs::create_dir_all(&home).expect("home config dir"); + std::fs::create_dir_all(workspace.join(".devo")).expect("workspace config dir"); + std::fs::write( + workspace.join(".devo").join("config.toml"), + "[permission]\ndefault_mode = 'auto'\n[[permission.rules]]\naction = 'allow'\ntool = 'bash'\npattern = 'git *'\n", + ) + .expect("write project config"); + let cli_overrides: toml::Value = r#" +[permission] +default_mode = "deny" + +[[permission.rules]] +action = "ask" +tool = "mcp" +pattern = "deploy" +"# + .parse() + .expect("parse cli overrides"); + + let config = FileSystemAppConfigLoader::new(home) + .with_cli_overrides(cli_overrides) + .load(Some(&workspace)) + .expect("load config"); + + assert_eq!( + config.permission, + PermissionConfig { + rules: vec![PermissionRule { + action: RuleAction::Ask, + tool: ToolFilter::Mcp, + pattern: Some("deploy".to_string()), + pattern_mode: PatternMode::Glob, + }], + prompt_policy: PromptPolicy::Deny, + } + ); + + let _ = std::fs::remove_dir_all(root); +} + #[test] fn default_app_config_enables_code_search() { assert_eq!( diff --git a/crates/rmcp-client/src/oauth.rs b/crates/rmcp-client/src/oauth.rs index d28a76aa..74107c49 100644 --- a/crates/rmcp-client/src/oauth.rs +++ b/crates/rmcp-client/src/oauth.rs @@ -14,7 +14,7 @@ //! keystore that always encrypts secrets when they are transferred across the bus. If DBus isn't installed the keystore will fall back to the json //! file because we don't use the "vendored" feature. //! -//! If the keyring is not available or fails, we fall back to CODEX_HOME/.credentials.json which is consistent with other coding CLI agents. +//! If the keyring is not available or fails, we fall back to DEVO_HOME/.credentials.json which is consistent with other coding CLI agents. use anyhow::Context; use anyhow::Error; @@ -597,21 +597,21 @@ mod tests { use devo_keyring_store::tests::MockKeyringStore; - struct TempCodexHome { + struct TempDevoHome { _guard: MutexGuard<'static, ()>, _dir: tempfile::TempDir, } - impl TempCodexHome { + impl TempDevoHome { fn new() -> Self { static LOCK: OnceLock> = OnceLock::new(); let guard = LOCK .get_or_init(Mutex::default) .lock() .unwrap_or_else(PoisonError::into_inner); - let dir = tempdir().expect("create CODEX_HOME temp dir"); + let dir = tempdir().expect("create DEVO_HOME temp dir"); unsafe { - std::env::set_var("CODEX_HOME", dir.path()); + std::env::set_var("DEVO_HOME", dir.path()); } Self { _guard: guard, @@ -620,17 +620,17 @@ mod tests { } } - impl Drop for TempCodexHome { + impl Drop for TempDevoHome { fn drop(&mut self) { unsafe { - std::env::remove_var("CODEX_HOME"); + std::env::remove_var("DEVO_HOME"); } } } #[test] fn load_oauth_tokens_reads_from_keyring_when_available() -> Result<()> { - let _env = TempCodexHome::new(); + let _env = TempDevoHome::new(); let store = MockKeyringStore::default(); let tokens = sample_tokens(); let expected = tokens.clone(); @@ -647,7 +647,7 @@ mod tests { #[test] fn load_oauth_tokens_falls_back_when_missing_in_keyring() -> Result<()> { - let _env = TempCodexHome::new(); + let _env = TempDevoHome::new(); let store = MockKeyringStore::default(); let tokens = sample_tokens(); let expected = tokens.clone(); @@ -666,7 +666,7 @@ mod tests { #[test] fn load_oauth_tokens_falls_back_when_keyring_errors() -> Result<()> { - let _env = TempCodexHome::new(); + let _env = TempDevoHome::new(); let store = MockKeyringStore::default(); let tokens = sample_tokens(); let expected = tokens.clone(); @@ -687,7 +687,7 @@ mod tests { #[test] fn save_oauth_tokens_prefers_keyring_when_available() -> Result<()> { - let _env = TempCodexHome::new(); + let _env = TempDevoHome::new(); let store = MockKeyringStore::default(); let tokens = sample_tokens(); let key = super::compute_store_key(&tokens.server_name, &tokens.url)?; @@ -709,7 +709,7 @@ mod tests { #[test] fn save_oauth_tokens_writes_fallback_when_keyring_fails() -> Result<()> { - let _env = TempCodexHome::new(); + let _env = TempDevoHome::new(); let store = MockKeyringStore::default(); let tokens = sample_tokens(); let key = super::compute_store_key(&tokens.server_name, &tokens.url)?; @@ -739,7 +739,7 @@ mod tests { #[test] fn delete_oauth_tokens_removes_all_storage() -> Result<()> { - let _env = TempCodexHome::new(); + let _env = TempDevoHome::new(); let store = MockKeyringStore::default(); let tokens = sample_tokens(); let serialized = serde_json::to_string(&tokens)?; @@ -761,7 +761,7 @@ mod tests { #[test] fn delete_oauth_tokens_file_mode_removes_keyring_only_entry() -> Result<()> { - let _env = TempCodexHome::new(); + let _env = TempDevoHome::new(); let store = MockKeyringStore::default(); let tokens = sample_tokens(); let serialized = serde_json::to_string(&tokens)?; @@ -783,7 +783,7 @@ mod tests { #[test] fn delete_oauth_tokens_propagates_keyring_errors() -> Result<()> { - let _env = TempCodexHome::new(); + let _env = TempDevoHome::new(); let store = MockKeyringStore::default(); let tokens = sample_tokens(); let key = super::compute_store_key(&tokens.server_name, &tokens.url)?; diff --git a/crates/safety/Cargo.toml b/crates/safety/Cargo.toml index 907f818e..feb3d946 100644 --- a/crates/safety/Cargo.toml +++ b/crates/safety/Cargo.toml @@ -7,11 +7,16 @@ version.workspace = true [dependencies] async-trait = { workspace = true } +devo-config = { workspace = true } +devo-util-shell-command = { workspace = true } +globset = { workspace = true } regex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } smol_str = { workspace = true } thiserror = { workspace = true } +tree-sitter = { workspace = true } +url = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/crates/safety/src/lib.rs b/crates/safety/src/lib.rs index 448aae6f..b11d4cb0 100644 --- a/crates/safety/src/lib.rs +++ b/crates/safety/src/lib.rs @@ -1,3 +1,4 @@ +pub mod permission; pub mod sandbox; use std::collections::BTreeSet; diff --git a/crates/safety/src/permission/bash_command_splitting.rs b/crates/safety/src/permission/bash_command_splitting.rs new file mode 100644 index 00000000..3f6898bf --- /dev/null +++ b/crates/safety/src/permission/bash_command_splitting.rs @@ -0,0 +1,633 @@ +//! Shell decomposition used for per-segment permission checks. + +use tree_sitter::Node; +use tree_sitter::Tree; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ParsedCommand { + words: Vec, + start_byte: usize, + end_byte: usize, +} + +impl ParsedCommand { + pub(super) fn words(&self) -> &[String] { + &self.words + } + + pub(super) fn start_byte(&self) -> usize { + self.start_byte + } + + pub(super) fn end_byte(&self) -> usize { + self.end_byte + } +} + +/// Decompose a script into literal command words. The public shell utility is +/// the fast path; this module adds the redirect- and compound-aware traversal +/// needed by the policy gates. +pub(super) fn all_commands_from_script(script: &str) -> Option> { + let tree = devo_util_shell_command::bash::try_parse_shell(script)?; + let commands = if let Some(commands) = + devo_util_shell_command::bash::try_parse_word_only_commands_sequence(&tree, script) + { + let nodes = command_nodes(tree.root_node()); + if commands.len() != nodes.len() { + return None; + } + commands + .into_iter() + .zip(nodes) + .map(|(words, node)| ParsedCommand { + words, + start_byte: node.start_byte(), + end_byte: command_effect_end(node), + }) + .collect() + } else { + parse_redirect_aware_commands(&tree, script)? + }; + if commands.iter().any(|command| { + matches!( + classify_shell_dash_c_script(unwrap_wrappers(command.words())), + ShellDashCScript::Uncertain + ) + }) { + // Callers already interpret decomposition failure as Ask. Preserve + // that fail-closed path when a literal shell command has no reliably + // positioned `-c` script. + return None; + } + Some(commands) +} + +fn parse_redirect_aware_commands(tree: &Tree, script: &str) -> Option> { + if tree.root_node().has_error() { + return None; + } + const ALLOWED_NAMED_KINDS: &[&str] = &[ + "program", + "list", + "pipeline", + "command", + "command_name", + "word", + "string", + "string_content", + "raw_string", + "number", + "concatenation", + "variable_assignment", + "variable_name", + "redirected_statement", + "file_redirect", + "file_descriptor", + "comment", + "heredoc_redirect", + "heredoc_start", + "heredoc_body", + "heredoc_content", + "heredoc_end", + // Compound shell syntax. Only literal `command` descendants are + // emitted; expansions and substitutions remain absent from this + // allowlist and therefore fail closed. + "subshell", + "compound_statement", + "function_definition", + "if_statement", + "elif_clause", + "else_clause", + "for_statement", + "while_statement", + "do_group", + "case_statement", + "case_item", + "negated_command", + "test_command", + "test_operator", + "unary_expression", + "binary_expression", + "parenthesized_expression", + "regex", + ]; + const ALLOWED_TOKENS: &[&str] = &[ + "&&", "||", ";", "|", "|&", "&", "!", "(", ")", "{", "}", "\"", "'", "=", ">", ">>", "<", + "<<", ">&", "&>", "&>>", "if", "then", "elif", "else", "fi", "for", "select", "in", "do", + "done", "while", "until", "case", "esac", ";;", ";&", ";;&", "function", "[[", "]]", "[", + "]", "-a", "-o", "!=", "=~", "==", "<=", ">=", "+", "-", "*", "/", "%", "**", "++", "--", + "^", ",", "?", ":", "~", + ]; + + let root = tree.root_node(); + let mut stack = vec![root]; + let mut command_nodes = Vec::new(); + while let Some(node) = stack.pop() { + if node.is_named() { + if !ALLOWED_NAMED_KINDS.contains(&node.kind()) { + return None; + } + if node.kind() == "command" { + command_nodes.push(node); + } + } else if !ALLOWED_TOKENS.contains(&node.kind()) && !node.kind().trim().is_empty() { + return None; + } + let mut cursor = node.walk(); + stack.extend(node.children(&mut cursor)); + } + command_nodes.sort_by_key(Node::start_byte); + command_nodes + .into_iter() + .map(|node| parse_plain_command(node, script)) + .collect() +} + +fn parse_plain_command(command: Node<'_>, script: &str) -> Option { + let mut words = Vec::new(); + let mut cursor = command.walk(); + for child in command.named_children(&mut cursor) { + match child.kind() { + "variable_assignment" | "file_redirect" | "heredoc_redirect" => {} + "command_name" => { + words.push(literal_arg(child.named_child(0)?, script)?); + } + "word" | "number" | "string" | "raw_string" | "concatenation" => { + words.push(literal_arg(child, script)?); + } + _ => return None, + } + } + (!words.is_empty()).then_some(ParsedCommand { + words, + start_byte: command.start_byte(), + end_byte: command_effect_end(command), + }) +} + +fn command_nodes(root: Node<'_>) -> Vec> { + let mut stack = vec![root]; + let mut commands = Vec::new(); + while let Some(node) = stack.pop() { + if node.kind() == "command" { + commands.push(node); + } + let mut cursor = node.walk(); + stack.extend(node.children(&mut cursor)); + } + commands.sort_by_key(Node::start_byte); + commands +} + +fn command_effect_end(mut node: Node<'_>) -> usize { + let mut end = node.end_byte(); + while let Some(parent) = node.parent() { + match parent.kind() { + "redirected_statement" => end = parent.end_byte(), + "list" | "pipeline" | "program" => break, + _ => {} + } + node = parent; + } + end +} + +fn literal_arg(node: Node<'_>, script: &str) -> Option { + let raw = node.utf8_text(script.as_bytes()).ok()?; + match node.kind() { + "word" | "number" => { + let mut cursor = node.walk(); + node.named_children(&mut cursor) + .next() + .is_none() + .then(|| raw.to_string()) + } + "raw_string" => raw + .strip_prefix('\'') + .and_then(|value| value.strip_suffix('\'')) + .map(str::to_owned), + "string" => { + let mut cursor = node.walk(); + node.named_children(&mut cursor) + .all(|child| child.kind() == "string_content") + .then(|| { + raw.strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + .unwrap_or(raw) + .to_string() + }) + } + "concatenation" => { + let mut result = String::new(); + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + result.push_str(&literal_arg(child, script)?); + } + (!result.is_empty()).then_some(result) + } + _ => None, + } +} + +pub(super) fn unwrap_wrappers(words: &[String]) -> &[String] { + let mut current = words; + for _ in 0..8 { + match strip_wrapper(current) { + Some(inner) => current = inner, + None => break, + } + } + current +} + +fn strip_wrapper(words: &[String]) -> Option<&[String]> { + let program = basename(words.first()?); + let mut index = 1; + match program { + "timeout" => { + while let Some(argument) = words.get(index) { + if !argument.starts_with('-') { + break; + } + index += if matches!(argument.as_str(), "-k" | "-s" | "--kill-after" | "--signal") { + 2 + } else { + 1 + }; + } + words.get(index)?; + index += 1; + } + "nice" => { + while let Some(argument) = words.get(index) { + if !argument.starts_with('-') { + break; + } + index += if matches!(argument.as_str(), "-n" | "--adjustment") { + 2 + } else { + 1 + }; + } + } + "ionice" => { + while let Some(argument) = words.get(index) { + if !argument.starts_with('-') { + break; + } + index += if matches!( + argument.as_str(), + "-c" | "-n" + | "-p" + | "-P" + | "-u" + | "--class" + | "--classdata" + | "--pid" + | "--pgid" + | "--uid" + ) { + 2 + } else { + 1 + }; + } + } + "chrt" => { + while words + .get(index) + .is_some_and(|argument| argument.starts_with('-')) + { + index += 1; + } + words.get(index)?; + index += 1; + } + "stdbuf" => { + while let Some(argument) = words.get(index) { + if !argument.starts_with('-') { + break; + } + index += if matches!(argument.as_str(), "-i" | "-o" | "-e") { + 2 + } else { + 1 + }; + } + } + "env" => index = env_inner_index(words), + "command" => { + while let Some(argument) = words.get(index) { + match argument.as_str() { + "--" => { + index += 1; + break; + } + "-p" => index += 1, + _ => break, + } + } + } + "exec" => { + while let Some(argument) = words.get(index) { + match argument.as_str() { + "--" => { + index += 1; + break; + } + "-a" => index += 2, + "-c" | "-l" => index += 1, + _ => break, + } + } + } + _ => return None, + } + words.get(index..).filter(|inner| !inner.is_empty()) +} + +fn env_inner_index(words: &[String]) -> usize { + let mut index = 1; + while let Some(argument) = words.get(index) { + if argument == "--" { + return index + 1; + } + if argument != "-" && argument.starts_with('-') { + index += if matches!( + argument.as_str(), + "-C" | "--chdir" | "-u" | "--unset" | "-S" | "--split-string" + ) { + 2 + } else { + 1 + }; + } else if is_env_assignment(argument) { + index += 1; + } else { + break; + } + } + index +} + +fn is_env_assignment(value: &str) -> bool { + let Some((name, _)) = value.split_once('=') else { + return false; + }; + !name.is_empty() + && name + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_') + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ShellDashCScript<'a> { + NotPresent, + Script(&'a str), + Uncertain, +} + +pub(super) fn shell_dash_c_script(words: &[String]) -> Option<&str> { + match classify_shell_dash_c_script(words) { + ShellDashCScript::Script(script) => Some(script), + ShellDashCScript::NotPresent | ShellDashCScript::Uncertain => None, + } +} + +pub(super) fn is_external_shell_script_invocation(words: &[String]) -> bool { + let Some(program) = words.first() else { + return false; + }; + if !matches!(basename(program), "bash" | "sh" | "dash" | "zsh" | "ksh") + || !matches!( + classify_shell_dash_c_script(words), + ShellDashCScript::NotPresent + ) + { + return false; + } + + let mut index = 1; + while let Some(argument) = words.get(index).map(String::as_str) { + if argument == "--" { + return words.get(index + 1).is_some(); + } + if argument == "-" { + return false; + } + if !argument.starts_with('-') || argument == "-c" { + return true; + } + index += if matches!(argument, "-o" | "-O" | "--init-file" | "--rcfile") { + 2 + } else { + 1 + }; + } + false +} + +fn classify_shell_dash_c_script(words: &[String]) -> ShellDashCScript<'_> { + let Some(program) = words.first() else { + return ShellDashCScript::NotPresent; + }; + if !matches!(basename(program), "bash" | "sh" | "dash" | "zsh" | "ksh") { + return ShellDashCScript::NotPresent; + } + + let mut index = 1; + let mut has_dash_c = false; + while let Some(word) = words.get(index).map(String::as_str) { + if matches!(word, "--" | "-") { + return match (has_dash_c, words.get(index + 1)) { + (true, Some(script)) => ShellDashCScript::Script(script), + (true, None) => ShellDashCScript::Uncertain, + (false, _) => ShellDashCScript::NotPresent, + }; + } + if let Some(long_option) = word.strip_prefix("--") { + if has_dash_c { + return ShellDashCScript::Uncertain; + } + index += if matches!(long_option, "init-file" | "rcfile") { + 2 + } else { + 1 + }; + continue; + } + let Some(cluster) = word + .strip_prefix('-') + .or_else(|| word.strip_prefix('+')) + .filter(|cluster| !cluster.is_empty()) + else { + return if has_dash_c { + ShellDashCScript::Script(word) + } else { + ShellDashCScript::NotPresent + }; + }; + + let enables_dash_c = word.starts_with('-') && cluster.contains('c'); + has_dash_c |= enables_dash_c; + let option_value_count = cluster + .bytes() + .filter(|option| matches!(option, b'o' | b'O')) + .count(); + index += 1; + for _ in 0..option_value_count { + let Some(value) = words.get(index).map(String::as_str) else { + return if has_dash_c { + ShellDashCScript::Uncertain + } else { + ShellDashCScript::NotPresent + }; + }; + if matches!(value, "--" | "-") { + return if has_dash_c { + ShellDashCScript::Uncertain + } else { + ShellDashCScript::NotPresent + }; + } + index += 1; + } + } + if has_dash_c { + ShellDashCScript::Uncertain + } else { + ShellDashCScript::NotPresent + } +} + +pub(super) fn basename(word: &str) -> &str { + word.rsplit(['/', '\\']).next().unwrap_or(word) +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + fn words(words: &[&str]) -> Vec { + words.iter().map(ToString::to_string).collect() + } + + #[test] + fn parses_literal_commands_in_subshells_and_conditionals() { + fn words(cmd: &ParsedCommand) -> Vec { + cmd.words().to_vec() + } + fn run(script: &str) -> Option>> { + let parsed = all_commands_from_script(script)?; + Some(parsed.iter().map(words).collect()) + } + assert_eq!(run("(rm /x)"), Some(vec![vec!["rm".into(), "/x".into()]])); + assert_eq!( + run("if true; then rm /x; fi"), + Some(vec![vec!["true".into()], vec!["rm".into(), "/x".into()],]) + ); + } + + #[test] + fn parses_literal_commands_in_loops_functions_and_background_pipelines() { + fn words(cmd: &ParsedCommand) -> Vec { + cmd.words().to_vec() + } + fn run(script: &str) -> Option>> { + let parsed = all_commands_from_script(script)?; + Some(parsed.iter().map(words).collect()) + } + assert_eq!( + run("for item in a b; do rm /x; done"), + Some(vec![vec!["rm".to_string(), "/x".to_string()]]) + ); + assert_eq!( + run("cleanup() { rm /x; }; cleanup"), + Some(vec![ + vec!["rm".to_string(), "/x".to_string()], + vec!["cleanup".to_string()], + ]) + ); + assert_eq!( + run("while true; do rm /x; done"), + Some(vec![ + vec!["true".to_string()], + vec!["rm".to_string(), "/x".to_string()], + ]) + ); + assert_eq!( + run("sleep 1 & echo safe | rm /x"), + Some(vec![ + vec!["sleep".to_string(), "1".to_string()], + vec!["echo".to_string(), "safe".to_string()], + vec!["rm".to_string(), "/x".to_string()], + ]) + ); + } + + #[test] + fn rejects_ambiguous_expansions_in_compound_commands() { + for script in [ + "(rm \"$TARGET\")", + "if true; then echo $(rm /x); fi", + "for item in \"$@\"; do rm \"$item\"; done", + ] { + assert_eq!(all_commands_from_script(script), None, "{script}"); + } + } + + #[test] + fn extracts_dash_c_script_around_value_taking_options() { + for (command, script) in [ + ( + words(&["bash", "-O", "extglob", "-o", "pipefail", "-lc", "rm /x"]), + "rm /x", + ), + ( + words(&["bash", "-lc", "-O", "extglob", "-o", "pipefail", "rm /x"]), + "rm /x", + ), + (words(&["bash", "-co", "pipefail", "rm /x"]), "rm /x"), + (words(&["bash", "-Oc", "extglob", "rm /x"]), "rm /x"), + (words(&["bash", "-c", "--", "rm /x"]), "rm /x"), + ] { + assert_eq!(shell_dash_c_script(&command), Some(script), "{command:?}"); + } + } + + #[test] + fn rejects_missing_or_unpositionable_dash_c_scripts() { + for command in [ + words(&["bash", "-c"]), + words(&["bash", "-c", "-O"]), + words(&["bash", "-c", "-o", "pipefail"]), + words(&["bash", "-O", "extglob", "-c"]), + words(&["bash", "-c", "--"]), + ] { + assert_eq!(shell_dash_c_script(&command), None, "{command:?}"); + assert_eq!( + classify_shell_dash_c_script(&command), + ShellDashCScript::Uncertain, + "{command:?}" + ); + } + for command in [ + words(&["bash", "-o", "-c", "rm /x"]), + words(&["bash", "--", "-c", "rm /x"]), + ] { + assert_eq!(shell_dash_c_script(&command), None, "{command:?}"); + assert_eq!( + classify_shell_dash_c_script(&command), + ShellDashCScript::NotPresent, + "{command:?}" + ); + } + assert_eq!(all_commands_from_script("bash -c -O"), None); + } +} diff --git a/crates/safety/src/permission/file_access_model.rs b/crates/safety/src/permission/file_access_model.rs new file mode 100644 index 00000000..3b1c7709 --- /dev/null +++ b/crates/safety/src/permission/file_access_model.rs @@ -0,0 +1,353 @@ +//! Infer file operands and access modes from one shell command invocation. + +use std::path::Path; + +use super::bash_command_splitting::basename; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum FileMode { + Read, + Grep { + glob: Option, + recursive: bool, + }, + Edit, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum ExtractedAccess { + Path { mode: FileMode, path: String }, + Ambiguous, +} + +pub(super) fn command_file_accesses(words: &[String]) -> Vec { + let Some(program) = words + .first() + .map(|word| basename(word).to_ascii_lowercase()) + else { + return Vec::new(); + }; + if program == "dd" { + return words + .iter() + .skip(1) + .filter_map(|word| { + word.strip_prefix("if=") + .and_then(|path| extracted_path(FileMode::Read, path.to_string())) + .or_else(|| { + word.strip_prefix("of=") + .and_then(|path| extracted_path(FileMode::Edit, path.to_string())) + }) + }) + .collect(); + } + + match program.as_str() { + "cp" | "mv" | "ln" | "install" => path_command_accesses(&program, words), + "rm" | "rmdir" | "mkdir" | "touch" => file_candidates(words) + .into_iter() + .filter_map(|path| extracted_path(FileMode::Edit, path.to_string())) + .collect(), + "tee" | "truncate" | "set-content" | "out-file" | "add-content" | "tee-object" => { + file_candidates(words) + .into_iter() + .filter_map(|path| extracted_path(FileMode::Edit, path.to_string())) + .collect() + } + "sed" if sed_is_in_place(words) => file_candidates(words) + .into_iter() + .skip(1) + .flat_map(|path| { + [FileMode::Read, FileMode::Edit] + .into_iter() + .filter_map(move |mode| extracted_path(mode, path.to_string())) + }) + .collect(), + "sort" => sort_accesses(words), + "go" | "git" | "rustc" => { + let accesses = output_paths(&program, words); + if accesses.is_empty() { + vec![ExtractedAccess::Ambiguous] + } else { + accesses + } + } + program if is_reader(program) => reader_accesses(program, words), + "rustfmt" => file_candidates(words) + .into_iter() + .filter_map(|path| extracted_path(FileMode::Edit, path.to_string())) + .collect(), + program if is_modeled_no_file(program) => Vec::new(), + _ => vec![ExtractedAccess::Ambiguous], + } +} + +fn path_command_accesses(program: &str, words: &[String]) -> Vec { + const TARGET_FLAGS: &[&str] = &["-t", "--target-directory"]; + let targets = value_flag_values(words, TARGET_FLAGS); + if targets.is_empty() { + let candidates = file_candidates(words); + let Some((destination, sources)) = candidates.split_last() else { + return Vec::new(); + }; + let mut accesses = Vec::new(); + for source in sources { + append_source_accesses(&mut accesses, program, source); + } + accesses.extend(extracted_path(FileMode::Edit, (*destination).to_string())); + return accesses; + } + let [target] = targets.as_slice() else { + return vec![ExtractedAccess::Ambiguous]; + }; + let sources = file_candidates_without_value_flags(words, TARGET_FLAGS); + if sources.is_empty() { + return vec![ExtractedAccess::Ambiguous]; + } + let mut accesses = Vec::new(); + for source in sources { + append_source_accesses(&mut accesses, program, source); + let Some(file_name) = Path::new(source).file_name() else { + accesses.push(ExtractedAccess::Ambiguous); + continue; + }; + let destination = Path::new(target) + .join(file_name) + .to_string_lossy() + .into_owned(); + if let Some(access) = extracted_path(FileMode::Edit, destination) { + accesses.push(access); + } + } + accesses +} + +fn append_source_accesses(accesses: &mut Vec, program: &str, source: &str) { + accesses.extend(extracted_path(FileMode::Read, source.to_string())); + if program == "mv" { + accesses.extend(extracted_path(FileMode::Edit, source.to_string())); + } +} + +fn reader_accesses(program: &str, words: &[String]) -> Vec { + const PATTERN_FLAGS: &[&str] = &["-e", "--regexp", "-f", "--file"]; + let search = matches!(program, "grep" | "egrep" | "fgrep" | "rg" | "ag" | "ack"); + if !search { + return file_candidates(words) + .into_iter() + .filter_map(|path| extracted_path(FileMode::Read, path.to_string())) + .collect(); + } + let patterns_from_flags = value_flag_values(words, PATTERN_FLAGS); + let pattern_files = value_flag_values(words, &["-f", "--file"]); + let candidates = file_candidates_without_value_flags(words, PATTERN_FLAGS); + let paths = if patterns_from_flags.is_empty() { + candidates.get(1..).unwrap_or_default() + } else { + candidates.as_slice() + }; + let recursive = matches!(program, "rg" | "ag" | "ack") + || (matches!(program, "grep" | "egrep" | "fgrep") && grep_is_recursive(words)); + let mut accesses = pattern_files + .into_iter() + .filter_map(|path| extracted_path(FileMode::Read, path.to_string())) + .chain(paths.iter().filter_map(|path| { + extracted_path( + FileMode::Grep { + glob: None, + recursive, + }, + (*path).to_string(), + ) + })) + .collect::>(); + if recursive && paths.is_empty() { + accesses.extend(extracted_path( + FileMode::Grep { + glob: None, + recursive: true, + }, + ".".to_string(), + )); + } + accesses +} + +fn grep_is_recursive(words: &[String]) -> bool { + words.iter().skip(1).any(|word| { + matches!(word.as_str(), "-r" | "-R" | "--recursive") + || word + .strip_prefix('-') + .filter(|options| !options.starts_with('-')) + .is_some_and(|options| options.contains(['r', 'R'])) + }) +} + +pub(super) fn extracted_path(mode: FileMode, path: String) -> Option { + if path == "-" || (matches!(mode, FileMode::Edit) && is_safe_write_sink(&path)) { + None + } else if path.contains(['*', '?', '[']) || path.starts_with("~/") { + Some(ExtractedAccess::Ambiguous) + } else { + Some(ExtractedAccess::Path { mode, path }) + } +} + +fn file_candidates(words: &[String]) -> Vec<&str> { + file_candidates_without_value_flags(words, &[]) +} + +fn file_candidates_without_value_flags<'a>( + words: &'a [String], + value_flags: &[&str], +) -> Vec<&'a str> { + let mut candidates = Vec::new(); + let mut options_ended = false; + let mut skip_value = false; + for word in words.iter().skip(1) { + if skip_value { + skip_value = false; + continue; + } + if !options_ended && word == "--" { + options_ended = true; + } else if !options_ended && value_flags.contains(&word.as_str()) { + skip_value = true; + } else if !options_ended + && value_flags + .iter() + .any(|flag| flag_value(word, flag).is_some()) + { + continue; + } else if options_ended || (word != "-" && !word.starts_with('-')) { + candidates.push(word.as_str()); + } + } + candidates +} + +fn value_flag_values<'a>(words: &'a [String], flags: &[&str]) -> Vec<&'a str> { + words + .iter() + .enumerate() + .flat_map(|(index, word)| { + flags.iter().filter_map(move |flag| { + flag_value(word, flag).or_else(|| { + (word == flag) + .then(|| words.get(index + 1).map(String::as_str)) + .flatten() + }) + }) + }) + .collect() +} + +fn flag_value<'a>(word: &'a str, flag: &str) -> Option<&'a str> { + word.strip_prefix(&format!("{flag}=")) + .or_else(|| { + (!flag.starts_with("--")) + .then(|| word.strip_prefix(flag)) + .flatten() + }) + .filter(|value| !value.is_empty()) +} + +fn output_paths(program: &str, words: &[String]) -> Vec { + let mut paths = value_flag_values(words, &["--output", "-o"]) + .into_iter() + .filter_map(|path| extracted_path(FileMode::Edit, path.to_string())) + .collect::>(); + if program == "rustc" { + let out_dirs = value_flag_values(words, &["--out-dir"]); + paths.extend( + out_dirs + .iter() + .filter_map(|path| extracted_path(FileMode::Edit, (*path).to_string())), + ); + if !out_dirs.is_empty() { + paths.push(ExtractedAccess::Ambiguous); + } + } + paths +} + +fn sort_accesses(words: &[String]) -> Vec { + const OUTPUT_FLAGS: &[&str] = &["--output", "-o"]; + file_candidates_without_value_flags(words, OUTPUT_FLAGS) + .into_iter() + .filter_map(|path| extracted_path(FileMode::Read, path.to_string())) + .chain( + value_flag_values(words, OUTPUT_FLAGS) + .into_iter() + .filter_map(|path| extracted_path(FileMode::Edit, path.to_string())), + ) + .collect() +} + +fn sed_is_in_place(words: &[String]) -> bool { + words.iter().skip(1).any(|word| { + word == "--in-place" + || word.starts_with("--in-place=") + || (word.starts_with('-') && !word.starts_with("--") && word.contains('i')) + }) +} + +fn is_reader(program: &str) -> bool { + matches!( + program, + "cat" + | "tac" + | "nl" + | "head" + | "tail" + | "grep" + | "egrep" + | "fgrep" + | "rg" + | "sed" + | "awk" + | "less" + | "more" + | "bat" + | "strings" + | "xxd" + | "od" + | "hexdump" + | "base64" + | "base32" + | "cut" + | "sort" + | "uniq" + | "wc" + | "diff" + | "comm" + | "jq" + | "yq" + | "ag" + | "ack" + ) +} + +fn is_modeled_no_file(program: &str) -> bool { + matches!( + program, + ":" | "[" + | "alias" + | "cd" + | "echo" + | "exit" + | "export" + | "false" + | "printf" + | "pwd" + | "test" + | "true" + | "type" + | "unalias" + | "unset" + ) +} + +fn is_safe_write_sink(path: &str) -> bool { + matches!(path, "/dev/null" | "/dev/stdout" | "/dev/stderr") +} diff --git a/crates/safety/src/permission/mod.rs b/crates/safety/src/permission/mod.rs new file mode 100644 index 00000000..b938da1b --- /dev/null +++ b/crates/safety/src/permission/mod.rs @@ -0,0 +1,13 @@ +//! Compiled permission rules and shell-aware access analysis. + +mod bash_command_splitting; +mod file_access_model; +mod policy; +mod rules; +mod shell_access; +mod types; + +pub use policy::CompiledPolicy; +pub use rules::PolicyCompileError; +pub use types::PermissionAccess; +pub use types::PolicyDecision; diff --git a/crates/safety/src/permission/policy.rs b/crates/safety/src/permission/policy.rs new file mode 100644 index 00000000..c55917b4 --- /dev/null +++ b/crates/safety/src/permission/policy.rs @@ -0,0 +1,141 @@ +use devo_config::PermissionConfig; + +use super::PermissionAccess; +use super::PolicyCompileError; +use super::PolicyDecision; +use super::bash_command_splitting::all_commands_from_script; +use super::bash_command_splitting::is_external_shell_script_invocation; +use super::bash_command_splitting::shell_dash_c_script; +use super::bash_command_splitting::unwrap_wrappers; +use super::rules::CompiledRule; +use super::shell_access::UnresolvedSymlinkPolicy; +use super::shell_access::evaluate_native_file_access; +use super::shell_access::evaluate_shell_file_access; + +/// Immutable, validated permission policy ready for runtime evaluation. +#[derive(Debug, Clone)] +pub struct CompiledPolicy { + rules: Vec, + has_file_restrictions: bool, + has_bash_restrictions: bool, +} + +impl CompiledPolicy { + /// Validate and compile every configured matcher. + pub fn compile(config: &PermissionConfig) -> Result { + let rules = config + .rules + .iter() + .enumerate() + .map(|(index, rule)| CompiledRule::compile(index, rule)) + .collect::, _>>()?; + let has_file_restrictions = rules.iter().any(CompiledRule::is_file_restriction); + let has_bash_restrictions = rules.iter().any(CompiledRule::is_bash_restriction); + Ok(Self { + rules, + has_file_restrictions, + has_bash_restrictions, + }) + } + + /// Evaluate one Devo-native access request. Matching actions use fixed + /// security precedence, independent of declaration order. + pub fn evaluate(&self, access: &PermissionAccess) -> PolicyDecision { + let direct = self + .evaluate_rules(access) + .combine(evaluate_native_file_access( + self, + access, + if self.has_file_restrictions { + UnresolvedSymlinkPolicy::Ask + } else { + UnresolvedSymlinkPolicy::Ignore + }, + )); + let PermissionAccess::Bash { command, cwd } = access else { + return direct; + }; + let bash = if self.has_bash_restrictions { + self.evaluate_bash_segments(command, cwd, 0) + } else { + PolicyDecision::NoMatch + }; + let files = if self.has_file_restrictions { + evaluate_shell_file_access(self, command, cwd) + } else { + PolicyDecision::NoMatch + }; + direct.combine(bash).combine(files) + } + + pub(super) fn evaluate_rules(&self, access: &PermissionAccess) -> PolicyDecision { + self.rules + .iter() + .filter(|rule| rule.matches(access)) + .map(CompiledRule::decision) + .fold(PolicyDecision::NoMatch, PolicyDecision::combine) + } + + pub(super) fn evaluate_recursive_scope(&self, access: &PermissionAccess) -> PolicyDecision { + self.rules + .iter() + .map(|rule| rule.recursive_scope_decision(access)) + .fold(PolicyDecision::NoMatch, PolicyDecision::combine) + } + + fn evaluate_bash_segments( + &self, + command: &str, + cwd: &std::path::Path, + depth: usize, + ) -> PolicyDecision { + const MAX_NESTING: usize = 8; + if depth >= MAX_NESTING { + return PolicyDecision::Ask; + } + let Some(commands) = all_commands_from_script(command) else { + return PolicyDecision::Ask; + }; + let mut decision = PolicyDecision::NoMatch; + for parsed in commands { + let raw = parsed.words(); + let inner = unwrap_wrappers(raw); + decision = decision.combine(self.evaluate_bash_words(raw, cwd)); + if inner.len() != raw.len() { + decision = decision.combine(self.evaluate_bash_words(inner, cwd)); + } + if let Some(script) = shell_dash_c_script(inner) { + decision = decision.combine(self.evaluate_bash_segments(script, cwd, depth + 1)); + } else if is_external_shell_script_invocation(inner) { + decision = decision.combine(PolicyDecision::Ask); + } + } + decision + } + + fn evaluate_bash_words(&self, words: &[String], cwd: &std::path::Path) -> PolicyDecision { + let raw = self + .evaluate_rules(&PermissionAccess::Bash { + command: words.join(" "), + cwd: cwd.to_path_buf(), + }) + .escalation_only(); + let Some((program, arguments)) = words.split_first() else { + return raw; + }; + let normalized_program = super::bash_command_splitting::basename(program); + if normalized_program == program { + return raw; + } + raw.combine( + self.evaluate_rules(&PermissionAccess::Bash { + command: std::iter::once(normalized_program) + .chain(arguments.iter().map(String::as_str)) + .collect::>() + .join(" "), + cwd: cwd.to_path_buf(), + }) + .escalation_only(), + ) + } +} diff --git a/crates/safety/src/permission/rules.rs b/crates/safety/src/permission/rules.rs new file mode 100644 index 00000000..571b62c3 --- /dev/null +++ b/crates/safety/src/permission/rules.rs @@ -0,0 +1,294 @@ +use std::path::Path; + +use devo_config::PatternMode; +use devo_config::PermissionRule; +use devo_config::RuleAction; +use devo_config::ToolFilter; +use globset::GlobBuilder; +use globset::GlobMatcher; +use thiserror::Error; +use url::Url; + +use super::PermissionAccess; +use super::PolicyDecision; + +/// A configuration error found while compiling permission matchers. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum PolicyCompileError { + #[error("invalid glob in permission rule {rule_index} ({pattern:?}): {message}")] + InvalidGlob { + rule_index: usize, + pattern: String, + message: String, + }, + #[error("invalid domain in permission rule {rule_index} ({pattern:?}): {message}")] + InvalidDomain { + rule_index: usize, + pattern: String, + message: String, + }, +} + +#[derive(Debug, Clone)] +struct CompiledGlob { + path: GlobMatcher, + freeform: GlobMatcher, +} + +#[derive(Debug, Clone)] +pub(super) struct CompiledRule { + action: RuleAction, + tool: ToolFilter, + pattern: Option, + pattern_mode: PatternMode, + glob: Option, + domain: Option, +} + +impl CompiledRule { + pub(super) fn compile( + rule_index: usize, + rule: &PermissionRule, + ) -> Result { + let mut glob = None; + let mut domain = None; + if let Some(pattern) = rule.pattern.as_deref() { + if matches!(rule.pattern_mode, PatternMode::Domain) { + domain = Some(normalize_domain_pattern(rule_index, pattern)?); + } + if pattern != "*" { + glob = Some(compile_glob(rule_index, pattern)?); + } + } + Ok(Self { + action: rule.action, + tool: rule.tool, + pattern: rule.pattern.clone(), + pattern_mode: rule.pattern_mode, + glob, + domain, + }) + } + + pub(super) fn is_file_restriction(&self) -> bool { + matches!(self.action, RuleAction::Deny | RuleAction::Ask) + && matches!( + self.tool, + ToolFilter::Any | ToolFilter::Read | ToolFilter::Edit | ToolFilter::Grep + ) + } + + pub(super) fn is_bash_restriction(&self) -> bool { + matches!(self.action, RuleAction::Deny | RuleAction::Ask) + && matches!(self.tool, ToolFilter::Any | ToolFilter::Bash) + } + + pub(super) fn matches(&self, access: &PermissionAccess) -> bool { + tool_filter_matches(access, self.tool) && self.pattern_matches(access) + } + + pub(super) fn decision(&self) -> PolicyDecision { + match self.action { + RuleAction::Allow => PolicyDecision::Allow, + RuleAction::Ask => PolicyDecision::Ask, + RuleAction::Deny => PolicyDecision::Deny { + reason: self.deny_reason(), + }, + } + } + + fn deny_reason(&self) -> String { + let label = tool_label(self.tool); + match self.pattern.as_deref() { + Some(pattern) => { + format!("Denied by permission policy: deny rule on {label} matching \"{pattern}\"") + } + None => format!("Denied by permission policy: deny rule on {label}"), + } + } + + fn pattern_matches(&self, access: &PermissionAccess) -> bool { + let Some(pattern) = self.pattern.as_deref() else { + return true; + }; + if pattern == "*" { + return true; + } + match access { + PermissionAccess::Read { + path: Some(path), + cwd: _, + } + | PermissionAccess::Edit { path, cwd: _ } => self.path_matches(path), + PermissionAccess::Read { path: None, cwd: _ } => false, + PermissionAccess::Grep { + path, + glob: _, + cwd: _, + recursive: _, + } => path.as_deref().is_some_and(|path| self.path_matches(path)), + PermissionAccess::Bash { command, cwd: _ } => { + let command = command.trim_start(); + command.starts_with(pattern) || self.freeform_matches(command) + } + PermissionAccess::Mcp { name, input: _ } => self.freeform_matches(name), + PermissionAccess::WebFetch(url) => match self.pattern_mode { + PatternMode::Domain => self.domain_matches(url), + PatternMode::Glob => self.freeform_matches(url), + }, + PermissionAccess::WebSearch(query) => { + query.starts_with(pattern) || self.freeform_matches(query) + } + } + } + + pub(super) fn recursive_scope_decision(&self, access: &PermissionAccess) -> PolicyDecision { + if matches!(self.action, RuleAction::Allow) || !tool_filter_matches(access, self.tool) { + return PolicyDecision::NoMatch; + } + self.decision() + } + + fn path_matches(&self, path: &Path) -> bool { + self.glob + .as_ref() + .is_some_and(|glob| glob.path.is_match(normalize_path(path))) + } + + fn freeform_matches(&self, value: &str) -> bool { + self.glob + .as_ref() + .is_some_and(|glob| glob.freeform.is_match(value)) + } + + fn domain_matches(&self, value: &str) -> bool { + let Some(expected) = self.domain.as_deref() else { + return false; + }; + let Some(actual) = normalize_url_host(value) else { + return false; + }; + actual == expected + || (!is_ip_host(expected) + && actual + .strip_suffix(expected) + .is_some_and(|prefix| prefix.ends_with('.'))) + } +} + +fn compile_glob(rule_index: usize, pattern: &str) -> Result { + const PATH_SEPARATOR_IS_LITERAL: bool = true; + const FREEFORM_SEPARATOR_IS_LITERAL: bool = false; + let build = |literal_separator| { + let mut builder = GlobBuilder::new(pattern); + builder.literal_separator(literal_separator); + builder.build().map(|glob| glob.compile_matcher()) + }; + let path = build(PATH_SEPARATOR_IS_LITERAL) + .map_err(|error| invalid_glob(rule_index, pattern, error.to_string()))?; + let freeform = build(FREEFORM_SEPARATOR_IS_LITERAL) + .map_err(|error| invalid_glob(rule_index, pattern, error.to_string()))?; + Ok(CompiledGlob { path, freeform }) +} + +fn invalid_glob(rule_index: usize, pattern: &str, message: String) -> PolicyCompileError { + let prefix = format!("error parsing glob '{pattern}': "); + PolicyCompileError::InvalidGlob { + rule_index, + pattern: pattern.to_string(), + message: message + .strip_prefix(&prefix) + .unwrap_or(&message) + .to_string(), + } +} + +fn normalize_domain_pattern( + rule_index: usize, + pattern: &str, +) -> Result { + if pattern.contains(['*', '?', '[', ']']) { + return Err(PolicyCompileError::InvalidDomain { + rule_index, + pattern: pattern.to_string(), + message: "domain patterns do not support wildcards".to_string(), + }); + } + let candidate = if pattern.contains("://") { + pattern.to_string() + } else { + format!("http://{pattern}") + }; + let parsed = Url::parse(&candidate).map_err(|error| PolicyCompileError::InvalidDomain { + rule_index, + pattern: pattern.to_string(), + message: error.to_string(), + })?; + if !parsed.username().is_empty() + || parsed.password().is_some() + || !matches!(parsed.path(), "" | "/") + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err(PolicyCompileError::InvalidDomain { + rule_index, + pattern: pattern.to_string(), + message: "domain pattern must not include credentials, a path, query, or fragment" + .to_string(), + }); + } + normalize_host(parsed.host_str()).ok_or_else(|| PolicyCompileError::InvalidDomain { + rule_index, + pattern: pattern.to_string(), + message: "domain pattern has no host".to_string(), + }) +} + +fn normalize_url_host(value: &str) -> Option { + let parsed = Url::parse(value).ok()?; + normalize_host(parsed.host_str()) +} + +fn normalize_host(host: Option<&str>) -> Option { + let normalized = host?.trim_end_matches('.').to_ascii_lowercase(); + (!normalized.is_empty()).then_some(normalized) +} + +fn is_ip_host(host: &str) -> bool { + host.trim_matches(['[', ']']) + .parse::() + .is_ok() +} + +fn normalize_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn tool_filter_matches(access: &PermissionAccess, filter: ToolFilter) -> bool { + match filter { + ToolFilter::Any => true, + ToolFilter::Bash => matches!(access, PermissionAccess::Bash { .. }), + ToolFilter::Edit => matches!(access, PermissionAccess::Edit { .. }), + ToolFilter::Read => matches!( + access, + PermissionAccess::Read { .. } | PermissionAccess::Grep { .. } + ), + ToolFilter::Grep => matches!(access, PermissionAccess::Grep { .. }), + ToolFilter::Mcp => matches!(access, PermissionAccess::Mcp { .. }), + ToolFilter::WebFetch => matches!(access, PermissionAccess::WebFetch(_)), + ToolFilter::WebSearch => matches!(access, PermissionAccess::WebSearch(_)), + } +} + +fn tool_label(tool: ToolFilter) -> &'static str { + match tool { + ToolFilter::Any => "any tool", + ToolFilter::Bash => "bash", + ToolFilter::Edit => "edit", + ToolFilter::Read => "read", + ToolFilter::Grep => "grep", + ToolFilter::Mcp => "mcp", + ToolFilter::WebFetch => "web_fetch", + ToolFilter::WebSearch => "web_search", + } +} diff --git a/crates/safety/src/permission/shell_access.rs b/crates/safety/src/permission/shell_access.rs new file mode 100644 index 00000000..6ee2304e --- /dev/null +++ b/crates/safety/src/permission/shell_access.rs @@ -0,0 +1,502 @@ +//! Detect file reads and writes embedded in shell commands. + +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; + +use tree_sitter::Node; + +use super::CompiledPolicy; +use super::PermissionAccess; +use super::PolicyDecision; +use super::bash_command_splitting::ParsedCommand; +use super::bash_command_splitting::all_commands_from_script; +use super::bash_command_splitting::basename; +use super::bash_command_splitting::shell_dash_c_script; +use super::bash_command_splitting::unwrap_wrappers; +use super::file_access_model::ExtractedAccess; +use super::file_access_model::FileMode; +use super::file_access_model::command_file_accesses; +use super::file_access_model::extracted_path; + +#[derive(Debug, Clone, Copy)] +enum PathDecisionMode { + Direct, + Shell, +} + +#[derive(Debug, Clone, Copy)] +pub(super) enum UnresolvedSymlinkPolicy { + Ask, + Ignore, +} + +pub(super) fn evaluate_native_file_access( + policy: &CompiledPolicy, + access: &PermissionAccess, + unresolved_symlink: UnresolvedSymlinkPolicy, +) -> PolicyDecision { + let (mode, path, cwd) = match access { + PermissionAccess::Read { + path: Some(path), + cwd, + } => (FileMode::Read, path, cwd), + PermissionAccess::Grep { + path: Some(path), + glob, + cwd, + recursive, + } => ( + FileMode::Grep { + glob: glob.clone(), + recursive: *recursive, + }, + path, + cwd, + ), + PermissionAccess::Edit { path, cwd } => (FileMode::Edit, path, cwd), + PermissionAccess::Read { path: None, cwd: _ } + | PermissionAccess::Grep { + path: None, + glob: _, + cwd: _, + recursive: false, + } + | PermissionAccess::Bash { .. } + | PermissionAccess::Mcp { .. } + | PermissionAccess::WebFetch(_) + | PermissionAccess::WebSearch(_) => return PolicyDecision::NoMatch, + PermissionAccess::Grep { + path: None, + glob, + cwd, + recursive: true, + } => { + return evaluate_path( + policy, + &FileMode::Grep { + glob: glob.clone(), + recursive: true, + }, + Path::new("."), + cwd, + PathDecisionMode::Direct, + unresolved_symlink, + ); + } + }; + evaluate_path( + policy, + &mode, + path, + cwd, + PathDecisionMode::Direct, + unresolved_symlink, + ) +} + +pub(super) fn evaluate_shell_file_access( + policy: &CompiledPolicy, + command: &str, + cwd: &Path, +) -> PolicyDecision { + evaluate_shell_file_access_at_depth(policy, command, cwd, 0) +} + +fn evaluate_shell_file_access_at_depth( + policy: &CompiledPolicy, + command: &str, + cwd: &Path, + depth: usize, +) -> PolicyDecision { + const MAX_NESTING: usize = 8; + if depth >= MAX_NESTING { + return PolicyDecision::Ask; + } + let Some(commands) = all_commands_from_script(command) else { + return PolicyDecision::Ask; + }; + let Some(tree) = devo_util_shell_command::bash::try_parse_shell(command) else { + return PolicyDecision::Ask; + }; + if tree.root_node().has_error() { + return PolicyDecision::Ask; + } + + let mut events = Vec::new(); + for parsed in &commands { + events.push((parsed.start_byte(), 0, ShellEvent::Command(parsed))); + if command_changes_cwd(parsed.words()) { + events.push((parsed.end_byte(), 2, ShellEvent::CwdChange(parsed))); + } + } + for (position, redirect) in redirect_accesses(tree.root_node(), command) { + events.push((position, 1, ShellEvent::Redirect(redirect))); + } + events.sort_by_key(|(position, priority, _)| (*position, *priority)); + + let mut decision = PolicyDecision::NoMatch; + let mut effective_cwd = Some(cwd.to_path_buf()); + for (_, _, event) in events { + match event { + ShellEvent::Command(parsed) => { + let raw = parsed.words(); + let words = unwrap_wrappers(raw); + if wrapper_changes_cwd(raw) { + decision = decision.combine(PolicyDecision::Ask); + } + if !command_changes_cwd(raw) { + for access in command_file_accesses(words) { + decision = decision.combine(evaluate_extracted_access( + policy, + access, + effective_cwd.as_deref(), + )); + } + } + if let Some(script) = shell_dash_c_script(words) { + decision = decision.combine(match effective_cwd.as_deref() { + Some(current_cwd) => evaluate_shell_file_access_at_depth( + policy, + script, + current_cwd, + depth + 1, + ), + None => PolicyDecision::Ask, + }); + } + } + ShellEvent::Redirect(access) => { + decision = decision.combine(evaluate_extracted_access( + policy, + access, + effective_cwd.as_deref(), + )); + } + ShellEvent::CwdChange(parsed) => { + effective_cwd = apply_cwd_change(parsed.words(), effective_cwd.as_deref()); + if effective_cwd.is_none() { + decision = decision.combine(PolicyDecision::Ask); + } + } + } + } + decision +} + +enum ShellEvent<'a> { + Command(&'a ParsedCommand), + Redirect(ExtractedAccess), + CwdChange(&'a ParsedCommand), +} + +fn evaluate_extracted_access( + policy: &CompiledPolicy, + access: ExtractedAccess, + cwd: Option<&Path>, +) -> PolicyDecision { + match access { + ExtractedAccess::Path { mode, path } => { + let path = Path::new(&path); + let Some(cwd) = cwd.or_else(|| path.is_absolute().then(|| Path::new("/"))) else { + return PolicyDecision::Ask; + }; + evaluate_path( + policy, + &mode, + path, + cwd, + PathDecisionMode::Shell, + UnresolvedSymlinkPolicy::Ask, + ) + } + ExtractedAccess::Ambiguous => PolicyDecision::Ask, + } +} + +fn command_changes_cwd(words: &[String]) -> bool { + unwrap_wrappers(words) + .first() + .is_some_and(|program| matches!(basename(program), "cd" | "pushd" | "popd")) +} + +fn apply_cwd_change(words: &[String], cwd: Option<&Path>) -> Option { + let words = unwrap_wrappers(words); + let program = basename(words.first()?); + if !matches!(program, "cd") { + return None; + } + let target = match words.get(1..)? { + [target] => target.as_str(), + [option, target] if option == "--" => target.as_str(), + _ => return None, + }; + if target == "-" || target.is_empty() { + return None; + } + let target = Path::new(target); + if target.is_absolute() { + Some(lexical_normalize(target)) + } else { + Some(lexical_normalize(&cwd?.join(target))) + } +} + +fn redirect_accesses(root: Node<'_>, source: &str) -> Vec<(usize, ExtractedAccess)> { + let mut found = Vec::new(); + let mut stack = vec![root]; + while let Some(node) = stack.pop() { + if node.kind() == "file_redirect" + && let Some(access) = redirect_access(node, source) + { + found.push((node.start_byte(), access)); + } + for index in 0..node.child_count() { + if let Some(child) = node.child(index) { + stack.push(child); + } + } + } + found +} + +fn redirect_access(node: Node<'_>, source: &str) -> Option { + let mut operator = None; + for index in 0..node.child_count() { + let kind = node.child(index)?.kind(); + if kind.contains("<<") { + return None; + } + if kind.contains('>') || kind.contains('<') { + operator = Some(kind); + break; + } + } + let operator = operator?; + let mode = if operator.contains('>') { + FileMode::Edit + } else { + FileMode::Read + }; + let destination = node.child_by_field_name("destination")?; + let Some(path) = literal_node(destination, source) else { + return Some(ExtractedAccess::Ambiguous); + }; + if path.is_empty() + || path.starts_with('&') + || (matches!(operator, ">&" | "<&") + && (path == "-" || path.bytes().all(|byte| byte.is_ascii_digit()))) + { + return None; + } + extracted_path(mode, path) +} + +fn literal_node(node: Node<'_>, source: &str) -> Option { + if has_expansion(node) { + return None; + } + let raw = node.utf8_text(source.as_bytes()).ok()?; + match node.kind() { + "word" | "number" | "concatenation" => Some(raw.to_string()), + "raw_string" => Some( + raw.strip_prefix('\'') + .and_then(|value| value.strip_suffix('\'')) + .unwrap_or(raw) + .to_string(), + ), + "string" => Some( + raw.strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + .unwrap_or(raw) + .to_string(), + ), + _ => None, + } +} + +fn has_expansion(node: Node<'_>) -> bool { + let mut stack = vec![node]; + while let Some(current) = stack.pop() { + for index in 0..current.child_count() { + let Some(child) = current.child(index) else { + continue; + }; + if matches!( + child.kind(), + "expansion" + | "simple_expansion" + | "command_substitution" + | "arithmetic_expansion" + | "process_substitution" + ) { + return true; + } + stack.push(child); + } + } + false +} + +fn evaluate_path( + policy: &CompiledPolicy, + mode: &FileMode, + path: &Path, + cwd: &Path, + decision_mode: PathDecisionMode, + unresolved_symlink: UnresolvedSymlinkPolicy, +) -> PolicyDecision { + let raw = path.to_path_buf(); + let raw_absolute = if raw.is_absolute() { + raw.clone() + } else { + cwd.join(&raw) + }; + let absolute = if raw.is_absolute() { + lexical_normalize(&raw) + } else { + lexical_normalize(&raw_absolute) + }; + let mut decision = path_decision(policy, mode, raw, cwd, decision_mode).combine(path_decision( + policy, + mode, + absolute.clone(), + cwd, + decision_mode, + )); + match resolve_following_symlinks(&raw_absolute, 0) { + Some(resolved) if resolved != absolute => { + decision = decision.combine(path_decision(policy, mode, resolved, cwd, decision_mode)); + } + Some(_) => {} + None if matches!(unresolved_symlink, UnresolvedSymlinkPolicy::Ask) + && path_has_symlink(&raw_absolute) => + { + decision = decision.combine(PolicyDecision::Ask); + } + None => {} + } + decision +} + +fn path_decision( + policy: &CompiledPolicy, + mode: &FileMode, + path: PathBuf, + cwd: &Path, + decision_mode: PathDecisionMode, +) -> PolicyDecision { + let access = match mode { + FileMode::Read => PermissionAccess::Read { + path: Some(path), + cwd: cwd.to_path_buf(), + }, + FileMode::Grep { glob, recursive } => PermissionAccess::Grep { + path: Some(path), + glob: glob.clone(), + cwd: cwd.to_path_buf(), + recursive: *recursive, + }, + FileMode::Edit => PermissionAccess::Edit { + path, + cwd: cwd.to_path_buf(), + }, + }; + let direct = policy.evaluate_rules(&access); + let recursive = matches!( + mode, + FileMode::Grep { + recursive: true, + .. + } + ) + .then(|| policy.evaluate_recursive_scope(&access)) + .unwrap_or(PolicyDecision::NoMatch); + match decision_mode { + PathDecisionMode::Direct => direct.combine(recursive), + PathDecisionMode::Shell => direct.combine(recursive).escalation_only(), + } +} + +fn lexical_normalize(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + if !normalized.pop() && !normalized.has_root() { + normalized.push(component); + } + } + Component::Prefix(_) | Component::RootDir | Component::Normal(_) => { + normalized.push(component); + } + } + } + normalized +} + +fn path_has_symlink(path: &Path) -> bool { + if !path.is_absolute() { + return false; + } + let mut prefix = PathBuf::new(); + for component in path.components() { + prefix.push(component); + if std::fs::symlink_metadata(&prefix) + .is_ok_and(|metadata| metadata.file_type().is_symlink()) + { + return true; + } + } + false +} + +fn resolve_following_symlinks(path: &Path, depth: usize) -> Option { + const MAX_SYMLINK_DEPTH: usize = 40; + if depth > MAX_SYMLINK_DEPTH || !path.is_absolute() { + return None; + } + if let Ok(canonical) = std::fs::canonicalize(path) { + return Some(lexical_normalize(&canonical)); + } + let parent = path.parent()?; + let file_name = path.file_name()?; + let resolved_parent = resolve_following_symlinks(parent, depth + 1)?; + let candidate = resolved_parent.join(file_name); + if let Ok(metadata) = std::fs::symlink_metadata(&candidate) + && metadata.file_type().is_symlink() + { + let target = std::fs::read_link(&candidate).ok()?; + let target = if target.is_absolute() { + target + } else { + resolved_parent.join(target) + }; + return resolve_following_symlinks(&target, depth + 1); + } + Some(lexical_normalize(&candidate)) +} + +fn wrapper_changes_cwd(words: &[String]) -> bool { + let mut current = words; + for _ in 0..8 { + if current.first().is_some_and(|word| basename(word) == "env") + && current.iter().any(|word| { + matches!(word.as_str(), "-C" | "--chdir") + || word.starts_with("--chdir=") + || word + .strip_prefix("-C") + .is_some_and(|value| !value.is_empty()) + }) + { + return true; + } + let inner = unwrap_wrappers(current); + if inner.len() == current.len() { + break; + } + current = inner; + } + false +} diff --git a/crates/safety/src/permission/types.rs b/crates/safety/src/permission/types.rs new file mode 100644 index 00000000..1f6b71e0 --- /dev/null +++ b/crates/safety/src/permission/types.rs @@ -0,0 +1,70 @@ +use std::path::PathBuf; + +/// A Devo tool access request evaluated by the compiled permission policy. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PermissionAccess { + Read { + path: Option, + cwd: PathBuf, + }, + Grep { + path: Option, + glob: Option, + cwd: PathBuf, + recursive: bool, + }, + Edit { + path: PathBuf, + cwd: PathBuf, + }, + Bash { + command: String, + cwd: PathBuf, + }, + Mcp { + name: String, + input: serde_json::Value, + }, + WebFetch(String), + WebSearch(String), +} + +/// Result of evaluating an access request against configured rules. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PolicyDecision { + Allow, + Deny { reason: String }, + Ask, + NoMatch, +} + +impl PolicyDecision { + pub(super) fn rank(&self) -> u8 { + match self { + Self::Deny { .. } => 3, + Self::Ask => 2, + Self::Allow => 1, + Self::NoMatch => 0, + } + } + + pub(super) fn combine(self, other: Self) -> Self { + match self.rank().cmp(&other.rank()) { + std::cmp::Ordering::Greater => self, + std::cmp::Ordering::Less => other, + std::cmp::Ordering::Equal => match (&self, &other) { + (Self::Deny { reason: left }, Self::Deny { reason: right }) if right < left => { + other + } + _ => self, + }, + } + } + + pub(super) fn escalation_only(self) -> Self { + match self { + Self::Deny { .. } | Self::Ask => self, + Self::Allow | Self::NoMatch => Self::NoMatch, + } + } +} diff --git a/crates/safety/tests/permission_policy.rs b/crates/safety/tests/permission_policy.rs new file mode 100644 index 00000000..b7d9557e --- /dev/null +++ b/crates/safety/tests/permission_policy.rs @@ -0,0 +1,865 @@ +use std::path::PathBuf; + +use devo_config::PatternMode; +use devo_config::PermissionConfig; +use devo_config::PermissionRule; +use devo_config::RuleAction; +use devo_config::ToolFilter; +use devo_safety::permission::CompiledPolicy; +use devo_safety::permission::PermissionAccess; +use devo_safety::permission::PolicyCompileError; +use devo_safety::permission::PolicyDecision; +use pretty_assertions::assert_eq; +use serde_json::json; + +fn rule( + action: RuleAction, + tool: ToolFilter, + pattern: Option<&str>, + pattern_mode: PatternMode, +) -> PermissionRule { + PermissionRule { + action, + tool, + pattern: pattern.map(str::to_owned), + pattern_mode, + } +} + +fn config(rules: Vec) -> PermissionConfig { + PermissionConfig { + rules, + ..PermissionConfig::default() + } +} + +fn deny(tool: &str, pattern: Option<&str>) -> PolicyDecision { + let reason = match pattern { + Some(pattern) => { + format!("Denied by permission policy: deny rule on {tool} matching \"{pattern}\"") + } + None => format!("Denied by permission policy: deny rule on {tool}"), + }; + PolicyDecision::Deny { reason } +} + +#[test] +fn compile_rejects_invalid_globs_and_domains() { + let glob_error = CompiledPolicy::compile(&config(vec![rule( + RuleAction::Deny, + ToolFilter::Read, + Some("["), + PatternMode::Glob, + )])) + .expect_err("invalid glob must fail compilation"); + assert_eq!( + glob_error, + PolicyCompileError::InvalidGlob { + rule_index: 0, + pattern: "[".to_string(), + message: "unclosed character class; missing ']'".to_string(), + } + ); + + let domain_error = CompiledPolicy::compile(&config(vec![rule( + RuleAction::Allow, + ToolFilter::WebFetch, + Some("bad domain"), + PatternMode::Domain, + )])) + .expect_err("invalid domain must fail compilation"); + assert_eq!( + domain_error, + PolicyCompileError::InvalidDomain { + rule_index: 0, + pattern: "bad domain".to_string(), + message: "invalid international domain name".to_string(), + } + ); + + let wildcard_domain_error = CompiledPolicy::compile(&config(vec![rule( + RuleAction::Allow, + ToolFilter::WebFetch, + Some("*.example.com"), + PatternMode::Domain, + )])) + .expect_err("wildcard domain must fail compilation"); + assert_eq!( + wildcard_domain_error, + PolicyCompileError::InvalidDomain { + rule_index: 0, + pattern: "*.example.com".to_string(), + message: "domain patterns do not support wildcards".to_string(), + } + ); +} + +#[test] +fn security_precedence_is_independent_of_rule_order() { + let allow = rule( + RuleAction::Allow, + ToolFilter::Bash, + Some("git *"), + PatternMode::Glob, + ); + let ask = rule( + RuleAction::Ask, + ToolFilter::Bash, + Some("git push*"), + PatternMode::Glob, + ); + let deny_rule = rule( + RuleAction::Deny, + ToolFilter::Bash, + Some("git push --force*"), + PatternMode::Glob, + ); + let access = PermissionAccess::Bash { + command: "git push --force origin main".to_string(), + cwd: PathBuf::from("/workspace"), + }; + + for rules in [ + vec![allow.clone(), ask.clone(), deny_rule.clone()], + vec![deny_rule.clone(), ask.clone(), allow.clone()], + ] { + let policy = CompiledPolicy::compile(&config(rules)).expect("compile policy"); + assert_eq!( + policy.evaluate(&access), + deny("bash", Some("git push --force*")) + ); + } + + for rules in [vec![allow.clone(), ask.clone()], vec![ask, allow]] { + let policy = CompiledPolicy::compile(&config(rules)).expect("compile policy"); + assert_eq!(policy.evaluate(&access), PolicyDecision::Ask); + } +} + +#[test] +fn path_and_tool_filters_match_only_their_access_kinds() { + let policy = CompiledPolicy::compile(&config(vec![rule( + RuleAction::Deny, + ToolFilter::Read, + Some("src/**/*.rs"), + PatternMode::Glob, + )])) + .expect("compile policy"); + + assert_eq!( + policy.evaluate(&PermissionAccess::Read { + path: Some(PathBuf::from("src/lib.rs")), + cwd: PathBuf::from("/workspace"), + }), + deny("read", Some("src/**/*.rs")) + ); + assert_eq!( + policy.evaluate(&PermissionAccess::Edit { + path: PathBuf::from("src/lib.rs"), + cwd: PathBuf::from("/workspace"), + }), + PolicyDecision::NoMatch + ); + assert_eq!( + policy.evaluate(&PermissionAccess::Read { + path: None, + cwd: PathBuf::from("/workspace"), + }), + PolicyDecision::NoMatch + ); +} + +#[test] +fn read_rules_govern_grep_paths() { + let policy = CompiledPolicy::compile(&config(vec![rule( + RuleAction::Deny, + ToolFilter::Read, + Some("**/.env"), + PatternMode::Glob, + )])) + .expect("compile policy"); + + assert_eq!( + policy.evaluate(&PermissionAccess::Grep { + path: Some(PathBuf::from("services/api/.env")), + glob: Some("*.env".to_string()), + cwd: PathBuf::from("/workspace"), + recursive: false, + }), + deny("read", Some("**/.env")) + ); + assert_eq!( + policy.evaluate(&PermissionAccess::Grep { + path: None, + glob: Some("*.env".to_string()), + cwd: PathBuf::from("/workspace"), + recursive: false, + }), + PolicyDecision::NoMatch + ); +} + +#[test] +fn native_file_access_resolves_relative_paths_against_cwd() { + let read = CompiledPolicy::compile(&config(vec![rule( + RuleAction::Deny, + ToolFilter::Read, + Some("/restricted/**"), + PatternMode::Glob, + )])) + .expect("compile read policy"); + let edit = CompiledPolicy::compile(&config(vec![rule( + RuleAction::Deny, + ToolFilter::Edit, + Some("/restricted/**"), + PatternMode::Glob, + )])) + .expect("compile edit policy"); + + for access in [ + PermissionAccess::Read { + path: Some(PathBuf::from("../restricted/secret.txt")), + cwd: PathBuf::from("/workspace"), + }, + PermissionAccess::Grep { + path: Some(PathBuf::from("../restricted/secret.txt")), + glob: None, + cwd: PathBuf::from("/workspace"), + recursive: false, + }, + ] { + assert_eq!(read.evaluate(&access), deny("read", Some("/restricted/**"))); + } + assert_eq!( + edit.evaluate(&PermissionAccess::Edit { + path: PathBuf::from("../restricted/secret.txt"), + cwd: PathBuf::from("/workspace"), + }), + deny("edit", Some("/restricted/**")) + ); +} + +#[cfg(unix)] +#[test] +fn native_and_shell_paths_resolve_dotdot_after_symlinks_physically() { + use std::os::unix::fs::symlink; + use std::time::SystemTime; + use std::time::UNIX_EPOCH; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time after epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "devo-permission-dotdot-symlink-{}-{nonce}", + std::process::id(), + )); + let workspace = root.join("workspace"); + let outside = root.join("outside"); + std::fs::create_dir_all(outside.join("dir")).expect("create outside tree"); + std::fs::create_dir_all(&workspace).expect("create workspace"); + std::fs::write(outside.join("secret.txt"), "secret").expect("write secret"); + symlink(outside.join("dir"), workspace.join("link")).expect("create symlink"); + + let canonical_outside = std::fs::canonicalize(&outside).expect("canonical outside"); + let pattern = format!("{}/**", canonical_outside.to_string_lossy()); + let policy = CompiledPolicy::compile(&config(vec![rule( + RuleAction::Deny, + ToolFilter::Read, + Some(&pattern), + PatternMode::Glob, + )])) + .expect("compile policy"); + let expected = deny("read", Some(&pattern)); + + assert_eq!( + policy.evaluate(&PermissionAccess::Read { + path: Some(PathBuf::from("link/../secret.txt")), + cwd: workspace.clone(), + }), + expected + ); + assert_eq!( + policy.evaluate(&PermissionAccess::Bash { + command: "cat link/../secret.txt".to_string(), + cwd: workspace, + }), + expected + ); + + std::fs::remove_dir_all(root).expect("remove test directory"); +} + +#[test] +fn domain_matching_normalizes_hosts_and_observes_label_boundaries() { + let policy = CompiledPolicy::compile(&config(vec![rule( + RuleAction::Allow, + ToolFilter::WebFetch, + Some("EXAMPLE.COM."), + PatternMode::Domain, + )])) + .expect("compile policy"); + + for url in [ + "https://example.com/docs", + "https://API.Example.Com.:443/docs", + "http://example.com:80/docs", + ] { + assert_eq!( + policy.evaluate(&PermissionAccess::WebFetch(url.to_string())), + PolicyDecision::Allow + ); + } + assert_eq!( + policy.evaluate(&PermissionAccess::WebFetch( + "https://notexample.com/docs".to_string() + )), + PolicyDecision::NoMatch + ); + assert_eq!( + policy.evaluate(&PermissionAccess::WebFetch( + "https://example.com.evil.test/docs".to_string() + )), + PolicyDecision::NoMatch + ); +} + +#[test] +fn mcp_and_web_search_use_owned_devo_native_values() { + let policy = CompiledPolicy::compile(&config(vec![ + rule( + RuleAction::Ask, + ToolFilter::Mcp, + Some("github__*"), + PatternMode::Glob, + ), + rule( + RuleAction::Allow, + ToolFilter::WebSearch, + Some("rust security"), + PatternMode::Glob, + ), + ])) + .expect("compile policy"); + + assert_eq!( + policy.evaluate(&PermissionAccess::Mcp { + name: "github__create_issue".to_string(), + input: json!({ "title": "Policy" }), + }), + PolicyDecision::Ask + ); + assert_eq!( + policy.evaluate(&PermissionAccess::WebSearch( + "rust security advisories".to_string() + )), + PolicyDecision::Allow + ); +} + +#[test] +fn bash_checks_every_compound_segment_and_common_wrappers() { + let policy = CompiledPolicy::compile(&config(vec![ + rule( + RuleAction::Allow, + ToolFilter::Bash, + Some("*"), + PatternMode::Glob, + ), + rule( + RuleAction::Deny, + ToolFilter::Bash, + Some("rm *"), + PatternMode::Glob, + ), + ])) + .expect("compile policy"); + + for command in [ + "echo safe && rm -rf build", + "echo safe; timeout 5 rm -rf build", + "env MODE=test nice -n 5 rm -rf build", + ] { + assert_eq!( + policy.evaluate(&PermissionAccess::Bash { + command: command.to_string(), + cwd: PathBuf::from("/workspace"), + }), + deny("bash", Some("rm *")) + ); + } +} + +#[test] +fn bash_recurses_into_nested_shell_dash_c_scripts() { + let policy = CompiledPolicy::compile(&config(vec![rule( + RuleAction::Deny, + ToolFilter::Bash, + Some("id"), + PatternMode::Glob, + )])) + .expect("compile policy"); + + for command in [ + "bash -c 'echo safe; id'", + "timeout 5 sh -c -- 'id > result.txt'", + ] { + assert_eq!( + policy.evaluate(&PermissionAccess::Bash { + command: command.to_string(), + cwd: PathBuf::from("/workspace"), + }), + deny("bash", Some("id")) + ); + } +} + +#[test] +fn shell_redirections_and_writer_paths_resolve_against_cwd() { + let policy = CompiledPolicy::compile(&config(vec![rule( + RuleAction::Deny, + ToolFilter::Edit, + Some("/workspace/private/**"), + PatternMode::Glob, + )])) + .expect("compile policy"); + + for command in [ + "echo secret > private/output.txt", + "printf secret | tee private/output.txt", + "touch private/output.txt", + "bash -c 'echo nested > private/output.txt'", + ] { + assert_eq!( + policy.evaluate(&PermissionAccess::Bash { + command: command.to_string(), + cwd: PathBuf::from("/workspace"), + }), + deny("edit", Some("/workspace/private/**")) + ); + } + + let escaped_policy = CompiledPolicy::compile(&config(vec![rule( + RuleAction::Deny, + ToolFilter::Edit, + Some("/private/**"), + PatternMode::Glob, + )])) + .expect("compile traversal policy"); + assert_eq!( + escaped_policy.evaluate(&PermissionAccess::Bash { + command: "echo secret > ../../private/output.txt".to_string(), + cwd: PathBuf::from("/workspace"), + }), + deny("edit", Some("/private/**")) + ); +} + +#[test] +fn shell_redirects_follow_literal_cwd_changes_in_source_order() { + let policy = CompiledPolicy::compile(&config(vec![rule( + RuleAction::Deny, + ToolFilter::Edit, + Some("/restricted/**"), + PatternMode::Glob, + )])) + .expect("compile policy"); + + for command in [ + "cd /restricted && echo secret > secret.txt", + "cd -- /restricted && echo secret > secret.txt", + "cd ../restricted && echo secret > secret.txt", + "echo safe > before.txt && cd /restricted && echo secret > secret.txt", + "cd /restricted > before.txt && echo secret > secret.txt", + ] { + assert_eq!( + policy.evaluate(&PermissionAccess::Bash { + command: command.to_string(), + cwd: PathBuf::from("/workspace"), + }), + deny("edit", Some("/restricted/**")), + "command: {command}" + ); + } + + assert_eq!( + policy.evaluate(&PermissionAccess::Bash { + command: "echo safe > before.txt && cd /restricted".to_string(), + cwd: PathBuf::from("/workspace"), + }), + PolicyDecision::NoMatch + ); + assert_eq!( + policy.evaluate(&PermissionAccess::Bash { + command: "cd \"$TARGET\" && echo secret > secret.txt".to_string(), + cwd: PathBuf::from("/workspace"), + }), + PolicyDecision::Ask + ); +} + +#[test] +fn shell_readers_cannot_bypass_read_rules() { + let policy = CompiledPolicy::compile(&config(vec![rule( + RuleAction::Deny, + ToolFilter::Read, + Some("**/.env"), + PatternMode::Glob, + )])) + .expect("compile policy"); + + assert_eq!( + policy.evaluate(&PermissionAccess::Bash { + command: "cat services/api/.env".to_string(), + cwd: PathBuf::from("/workspace"), + }), + deny("read", Some("**/.env")) + ); +} + +#[test] +fn shell_searches_and_pattern_files_are_inferred_as_reads() { + let cwd_policy = CompiledPolicy::compile(&config(vec![rule( + RuleAction::Deny, + ToolFilter::Read, + Some("/workspace"), + PatternMode::Glob, + )])) + .expect("compile cwd policy"); + assert_eq!( + cwd_policy.evaluate(&PermissionAccess::Bash { + command: "rg needle".to_string(), + cwd: PathBuf::from("/workspace"), + }), + deny("read", Some("/workspace")) + ); + + let pattern_policy = CompiledPolicy::compile(&config(vec![rule( + RuleAction::Deny, + ToolFilter::Read, + Some("**/.env"), + PatternMode::Glob, + )])) + .expect("compile pattern-file policy"); + assert_eq!( + pattern_policy.evaluate(&PermissionAccess::Bash { + command: "grep -f .env README.md".to_string(), + cwd: PathBuf::from("/workspace"), + }), + deny("read", Some("**/.env")) + ); +} + +#[test] +fn recursive_shell_searches_enforce_descendant_read_denies() { + let policy = CompiledPolicy::compile(&config(vec![rule( + RuleAction::Deny, + ToolFilter::Read, + Some("**/.env"), + PatternMode::Glob, + )])) + .expect("compile policy"); + + for command in [ + "rg needle .", + "rg needle", + "rg needle services", + "grep -r needle .", + "grep -R needle services", + "grep --recursive needle .", + ] { + assert_eq!( + policy.evaluate(&PermissionAccess::Bash { + command: command.to_string(), + cwd: PathBuf::from("/workspace"), + }), + deny("read", Some("**/.env")), + "command: {command}" + ); + } + + let grep_policy = CompiledPolicy::compile(&config(vec![rule( + RuleAction::Deny, + ToolFilter::Grep, + Some("**/.env"), + PatternMode::Glob, + )])) + .expect("compile grep policy"); + assert_eq!( + grep_policy.evaluate(&PermissionAccess::Bash { + command: "rg needle .".to_string(), + cwd: PathBuf::from("/workspace"), + }), + deny("grep", Some("**/.env")) + ); +} + +#[test] +fn bash_rules_normalize_executables_and_peel_execution_wrappers() { + let policy = CompiledPolicy::compile(&config(vec![rule( + RuleAction::Deny, + ToolFilter::Bash, + Some("rm *"), + PatternMode::Glob, + )])) + .expect("compile policy"); + + for command in [ + "command rm -rf build", + "exec rm -rf build", + "/bin/rm -rf build", + "command exec /bin/rm -rf build", + "timeout 5 command /bin/rm -rf build", + ] { + assert_eq!( + policy.evaluate(&PermissionAccess::Bash { + command: command.to_string(), + cwd: PathBuf::from("/workspace"), + }), + deny("bash", Some("rm *")), + "command: {command}" + ); + } +} + +#[test] +fn external_shell_scripts_ask_under_bash_restrictions() { + let policy = CompiledPolicy::compile(&config(vec![rule( + RuleAction::Deny, + ToolFilter::Bash, + Some("rm *"), + PatternMode::Glob, + )])) + .expect("compile policy"); + + for command in [ + "bash script.sh", + "sh ./x", + "command /bin/bash script.sh", + "exec sh ./x", + "timeout 5 command exec /bin/sh ./x", + ] { + assert_eq!( + policy.evaluate(&PermissionAccess::Bash { + command: command.to_string(), + cwd: PathBuf::from("/workspace"), + }), + PolicyDecision::Ask, + "command: {command}" + ); + } +} + +#[test] +fn in_place_sed_is_both_a_read_and_an_edit() { + let policy = CompiledPolicy::compile(&config(vec![rule( + RuleAction::Deny, + ToolFilter::Read, + Some("**/secret.txt"), + PatternMode::Glob, + )])) + .expect("compile policy"); + + assert_eq!( + policy.evaluate(&PermissionAccess::Bash { + command: "sed -i 's/old/new/' private/secret.txt".to_string(), + cwd: PathBuf::from("/workspace"), + }), + deny("read", Some("**/secret.txt")) + ); +} + +#[test] +fn unmodeled_shell_commands_fail_closed_only_with_file_restrictions() { + let read_restricted = CompiledPolicy::compile(&config(vec![rule( + RuleAction::Deny, + ToolFilter::Read, + Some("**/.env"), + PatternMode::Glob, + )])) + .expect("compile read policy"); + let edit_restricted = CompiledPolicy::compile(&config(vec![rule( + RuleAction::Deny, + ToolFilter::Edit, + Some("**/secret.txt"), + PatternMode::Glob, + )])) + .expect("compile edit policy"); + let unrestricted = + CompiledPolicy::compile(&PermissionConfig::default()).expect("compile empty policy"); + + for command in ["python script.py", "unknown-tool input.txt"] { + let access = PermissionAccess::Bash { + command: command.to_string(), + cwd: PathBuf::from("/workspace"), + }; + assert_eq!(read_restricted.evaluate(&access), PolicyDecision::Ask); + assert_eq!(edit_restricted.evaluate(&access), PolicyDecision::Ask); + assert_eq!(unrestricted.evaluate(&access), PolicyDecision::NoMatch); + } + + for command in ["echo safe", "printf safe", "pwd", "true"] { + let access = PermissionAccess::Bash { + command: command.to_string(), + cwd: PathBuf::from("/workspace"), + }; + assert_eq!(read_restricted.evaluate(&access), PolicyDecision::NoMatch); + assert_eq!(edit_restricted.evaluate(&access), PolicyDecision::NoMatch); + } +} + +#[test] +fn shell_move_and_output_placement_are_modeled() { + let policy = CompiledPolicy::compile(&config(vec![rule( + RuleAction::Deny, + ToolFilter::Edit, + Some("/workspace/restricted/**"), + PatternMode::Glob, + )])) + .expect("compile policy"); + + for command in [ + "mv restricted/source.txt /tmp/destination.txt", + "cp -t restricted source.txt", + "mv --target-directory=restricted source.txt", + "sort --output=restricted/sorted.txt input.txt", + ] { + assert_eq!( + policy.evaluate(&PermissionAccess::Bash { + command: command.to_string(), + cwd: PathBuf::from("/workspace"), + }), + deny("edit", Some("/workspace/restricted/**")), + "command: {command}" + ); + } + + let read_policy = CompiledPolicy::compile(&config(vec![rule( + RuleAction::Deny, + ToolFilter::Read, + Some("/workspace/restricted/**"), + PatternMode::Glob, + )])) + .expect("compile read policy"); + assert_eq!( + read_policy.evaluate(&PermissionAccess::Bash { + command: "mv restricted/source.txt /tmp/destination.txt".to_string(), + cwd: PathBuf::from("/workspace"), + }), + deny("read", Some("/workspace/restricted/**")) + ); + + let out_dir_policy = CompiledPolicy::compile(&config(vec![rule( + RuleAction::Deny, + ToolFilter::Edit, + Some("/workspace/restricted"), + PatternMode::Glob, + )])) + .expect("compile out-dir policy"); + assert_eq!( + out_dir_policy.evaluate(&PermissionAccess::Bash { + command: "rustc src/main.rs --out-dir restricted".to_string(), + cwd: PathBuf::from("/workspace"), + }), + deny("edit", Some("/workspace/restricted")) + ); +} + +#[cfg(unix)] +#[test] +fn shell_paths_are_rechecked_after_following_symlinks() { + use std::os::unix::fs::symlink; + use std::time::SystemTime; + use std::time::UNIX_EPOCH; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time after epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "devo-permission-symlink-{}-{nonce}", + std::process::id() + )); + let restricted = root.join("restricted"); + let workspace = root.join("workspace"); + std::fs::create_dir_all(&restricted).expect("create restricted dir"); + std::fs::create_dir_all(&workspace).expect("create workspace dir"); + std::fs::write(restricted.join("secret.txt"), "secret").expect("write secret"); + symlink(&restricted, workspace.join("alias")).expect("create symlink"); + + let canonical_restricted = std::fs::canonicalize(&restricted).expect("canonical restricted"); + let pattern = format!("{}/**", canonical_restricted.to_string_lossy()); + let policy = CompiledPolicy::compile(&config(vec![rule( + RuleAction::Deny, + ToolFilter::Read, + Some(&pattern), + PatternMode::Glob, + )])) + .expect("compile symlink policy"); + assert_eq!( + policy.evaluate(&PermissionAccess::Bash { + command: "cat alias/secret.txt".to_string(), + cwd: workspace, + }), + deny("read", Some(&pattern)) + ); + + std::fs::remove_dir_all(root).expect("remove test directory"); +} + +#[test] +fn malformed_shell_fails_closed_and_modeled_shell_does_not_ask() { + let restricted = CompiledPolicy::compile(&config(vec![rule( + RuleAction::Deny, + ToolFilter::Edit, + Some("**/.env"), + PatternMode::Glob, + )])) + .expect("compile policy"); + let unrestricted = + CompiledPolicy::compile(&PermissionConfig::default()).expect("compile empty policy"); + let bash_restricted = CompiledPolicy::compile(&config(vec![rule( + RuleAction::Deny, + ToolFilter::Bash, + Some("rm *"), + PatternMode::Glob, + )])) + .expect("compile bash policy"); + let access = PermissionAccess::Bash { + command: "echo $(cat .env".to_string(), + cwd: PathBuf::from("/workspace"), + }; + + assert_eq!(restricted.evaluate(&access), PolicyDecision::Ask); + assert_eq!(bash_restricted.evaluate(&access), PolicyDecision::Ask); + assert_eq!(unrestricted.evaluate(&access), PolicyDecision::NoMatch); + + let supported = PermissionAccess::Bash { + command: "if true; then echo safe; fi".to_string(), + cwd: PathBuf::from("/workspace"), + }; + assert_eq!( + bash_restricted.evaluate(&supported), + PolicyDecision::NoMatch + ); + assert_eq!(unrestricted.evaluate(&supported), PolicyDecision::NoMatch); +} + +#[test] +fn file_allow_rules_do_not_auto_allow_a_bash_command() { + let policy = CompiledPolicy::compile(&config(vec![rule( + RuleAction::Allow, + ToolFilter::Edit, + Some("/workspace/out.txt"), + PatternMode::Glob, + )])) + .expect("compile policy"); + + assert_eq!( + policy.evaluate(&PermissionAccess::Bash { + command: "echo ok > out.txt".to_string(), + cwd: PathBuf::from("/workspace"), + }), + PolicyDecision::NoMatch + ); +} diff --git a/crates/sandbox/Cargo.toml b/crates/sandbox/Cargo.toml new file mode 100644 index 00000000..8875ce9e --- /dev/null +++ b/crates/sandbox/Cargo.toml @@ -0,0 +1,58 @@ +[package] +description = "OS-level sandboxing for Devo using kernel primitives (Landlock/Seatbelt) via nono" +edition.workspace = true +license = "Apache-2.0" +name = "devo-sandbox" +version.workspace = true + +[dependencies] +anyhow = { workspace = true } +chrono = { workspace = true, features = ["serde"] } +devo-util-paths = { workspace = true } +dirs = { workspace = true } +dunce = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +toml = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } + +[target.'cfg(unix)'.dependencies] +libc = { workspace = true } +# Pinned exact: the macOS Seatbelt deny precedence (see deny.rs `emit_seatbelt_deny`) +# depends on nono's observed rule-emission order. A bump can silently change it and +# re-open the `mv x y && cat y` bypass with `is_applied()` still true, so re-verify +# `deny_paths_e2e` on real macOS (it self-skips in CI) before bumping. +# +# Non-optional (not gated on `enforce`): Cargo has no target-conditional features +# table, so a `dep:nono` reference from the cross-platform `enforce` feature would +# break feature resolution on non-unix targets (e.g. Windows builds). nono/globset +# are unix-only here, so they only compile on unix anyway; the enforce *code* +# stays gated on `cfg(all(feature = "enforce", unix))`. +nono = { workspace = true } +# globset validates every deny glob on BOTH platforms (so a glob is interpreted +# identically or rejected identically) and matches them on Linux. Non-optional for +# the same reason as nono above. +globset = { workspace = true } + +# Linux additionally walks the tree to expand globs to existing paths (enforce+ +# linux only). cfg(linux)-gated rather than enforce-gated: the cross-platform +# enforce feature can't reference a linux-only optional dep without breaking macOS +# feature resolution, so a `--no-default-features` Linux build pulls it unused. +[target.'cfg(target_os = "linux")'.dependencies] +ignore = { workspace = true } + +[features] +default = ["enforce"] +## Enable kernel-enforced sandboxing via nono (Landlock/Seatbelt). Marker feature +## only: the backing deps (nono/globset) are non-optional unix-only deps (see +## above), and the enforce code is gated on `cfg(all(feature = "enforce", unix))`. +## On non-unix targets enabling this feature is a no-op (no kernel sandbox exists). +enforce = [] + +[dev-dependencies] +pretty_assertions = { workspace = true } +regex = { workspace = true } +serial_test = { workspace = true } diff --git a/crates/sandbox/src/bwrap.rs b/crates/sandbox/src/bwrap.rs new file mode 100644 index 00000000..51133e62 --- /dev/null +++ b/crates/sandbox/src/bwrap.rs @@ -0,0 +1,728 @@ +//! Linux bwrap re-exec helpers and their fail-closed read-deny placeholders. + +#[cfg(any(target_os = "linux", all(feature = "enforce", unix)))] +use crate::profiles; +use crate::profiles::ProfileName; +#[cfg(target_os = "linux")] +use crate::profiles::SandboxConfig; +#[cfg(target_os = "linux")] +use crate::{deny, paths}; +#[cfg(target_os = "linux")] +use anyhow::Context; +use std::path::Path; +#[cfg(target_os = "linux")] +use std::path::PathBuf; + +const BWRAP_ENV_VAR: &str = "__DEVO_INSIDE_BWRAP"; +const BWRAP_MARKER_VALUE: &str = "devo-bwrap-v1"; + +#[cfg(any(target_os = "linux", test))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MountNamespaceIsolation { + Isolated, + SharedWithPidOne, + Unknown, +} + +/// Whether this process is the bwrap re-exec created by this crate. +pub fn is_inside_bwrap() -> bool { + is_inside_bwrap_with_marker(std::env::var_os(BWRAP_ENV_VAR).as_deref()) +} + +fn is_inside_bwrap_with_marker(marker: Option<&std::ffi::OsStr>) -> bool { + #[cfg(target_os = "linux")] + { + // A bwrap re-exec always creates a new mount namespace. Reject the + // marker unless procfs affirmatively proves that isolation boundary. + return has_bwrap_marker_and_mount_namespace(marker, bwrap_mount_namespace_isolation()); + } + + #[cfg(not(target_os = "linux"))] + { + has_exact_bwrap_marker(marker) + } +} + +fn has_exact_bwrap_marker(marker: Option<&std::ffi::OsStr>) -> bool { + marker.is_some_and(|value| value == std::ffi::OsStr::new(BWRAP_MARKER_VALUE)) +} + +#[cfg(any(target_os = "linux", test))] +fn has_bwrap_marker_and_mount_namespace( + marker: Option<&std::ffi::OsStr>, + namespace_isolation: MountNamespaceIsolation, +) -> bool { + has_exact_bwrap_marker(marker) + && matches!(namespace_isolation, MountNamespaceIsolation::Isolated) +} + +#[cfg(target_os = "linux")] +fn bwrap_mount_namespace_isolation() -> MountNamespaceIsolation { + let Ok(current) = std::fs::read_link("/proc/self/ns/mnt") else { + return MountNamespaceIsolation::Unknown; + }; + let Ok(pid_one) = std::fs::read_link("/proc/1/ns/mnt") else { + return MountNamespaceIsolation::Unknown; + }; + if current == pid_one { + MountNamespaceIsolation::SharedWithPidOne + } else { + MountNamespaceIsolation::Isolated + } +} + +/// Kept closed until a separate devbox-specific trust policy is introduced. +pub fn trust_bwrap_marker_for_devbox() -> bool { + false +} + +/// Build a bwrap command that re-execs the current process with `deny_write` +/// paths mounted read-only and `deny_read` paths bound over with an unreadable +/// placeholder (EPERM on read). +/// +/// Returns `Ok(None)` only if already inside bwrap. Caller should `cmd.exec()` +/// the returned command; construction failures are returned as errors. +pub fn bwrap_reexec_command( + deny_write: &[&str], + deny_read: &[&str], +) -> anyhow::Result> { + bwrap_reexec_command_with_state(deny_write, deny_read, is_inside_bwrap()) +} + +fn bwrap_reexec_command_with_state( + deny_write: &[&str], + deny_read: &[&str], + inside_bwrap: bool, +) -> anyhow::Result> { + if inside_bwrap { + return Ok(None); + } + let self_exe = std::env::current_exe()?; + let args: Vec = std::env::args().skip(1).collect(); + let mut cmd = std::process::Command::new("bwrap"); + cmd.arg("--bind").arg("/").arg("/"); + for path in deny_write { + if Path::new(path).exists() { + cmd.arg("--ro-bind").arg(path).arg(path); + } + } + #[cfg(target_os = "linux")] + if !deny_read.is_empty() { + for path in deny_read { + let blocked = bwrap_blocked_source_for_path(Path::new(path)).with_context(|| { + format!( + "could not create bwrap placeholder for read-deny path {path}; \ + refusing to start with a partial sandbox" + ) + })?; + cmd.arg("--ro-bind").arg(&blocked).arg(path); + } + } + #[cfg(not(target_os = "linux"))] + let _ = deny_read; + cmd.arg("--dev-bind").arg("/dev").arg("/dev"); + cmd.arg("--proc").arg("/proc"); + cmd.env(BWRAP_ENV_VAR, BWRAP_MARKER_VALUE); + cmd.arg("--").arg(self_exe).args(args); + Ok(Some(cmd)) +} + +/// Choose file vs directory placeholder for a deny path (existing dirs need a dir bind). +#[cfg(all(feature = "enforce", target_os = "linux"))] +fn bwrap_blocked_source_for_path(path: &Path) -> anyhow::Result { + if deny::deny_path_is_dir(path) { + bwrap_blocked_placeholder(BwrapPlaceholderKind::Directory) + } else { + bwrap_blocked_placeholder(BwrapPlaceholderKind::File) + } +} + +/// Without kernel enforcement there are no read-deny placeholders to bind over. +#[cfg(all(not(feature = "enforce"), target_os = "linux"))] +fn bwrap_blocked_source_for_path(_path: &Path) -> anyhow::Result { + anyhow::bail!("bwrap read-deny placeholders require the 'enforce' feature") +} + +/// chmod a placeholder to mode 000 so a bwrap bind-over yields EPERM on read. +#[cfg(all(feature = "enforce", target_os = "linux"))] +fn chmod_000(path: &Path) -> anyhow::Result<()> { + use std::os::unix::fs::PermissionsExt; + + let mut permissions = std::fs::metadata(path) + .with_context(|| format!("could not inspect bwrap placeholder {}", path.display()))? + .permissions(); + permissions.set_mode(0o000); + std::fs::set_permissions(path, permissions) + .with_context(|| format!("could not secure bwrap placeholder {}", path.display()))?; + Ok(()) +} + +#[cfg(all(feature = "enforce", target_os = "linux"))] +#[derive(Clone, Copy)] +enum BwrapPlaceholderKind { + File, + Directory, +} + +#[cfg(all(feature = "enforce", target_os = "linux"))] +impl BwrapPlaceholderKind { + fn name(self) -> &'static str { + match self { + Self::File => "sandbox-blocked", + Self::Directory => "sandbox-blocked-dir", + } + } + + fn is_expected_type(self, file_type: std::fs::FileType) -> bool { + match self { + Self::File => file_type.is_file(), + Self::Directory => file_type.is_dir(), + } + } +} + +/// Zero-permission placeholder (file or dir) in a private random directory +/// under `devo_home`, used by bwrap bind-over. +/// +/// The old PID-named files were predictable entries in a writable home and +/// could be replaced with symlinks. Each bwrap launch now creates a mode-0700 +/// directory with `mkdtemp`, then creates the placeholder exclusively inside it. +#[cfg(all(feature = "enforce", target_os = "linux"))] +fn bwrap_blocked_placeholder(kind: BwrapPlaceholderKind) -> anyhow::Result { + let directory = create_private_bwrap_placeholder_directory()?; + bwrap_blocked_placeholder_in(&directory, kind) +} + +#[cfg(all(feature = "enforce", target_os = "linux"))] +fn create_private_bwrap_placeholder_directory() -> anyhow::Result { + use std::os::unix::ffi::OsStringExt; + use std::os::unix::fs::PermissionsExt; + + let devo_home = paths::devo_home().context("could not resolve bwrap placeholder root")?; + match std::fs::symlink_metadata(&devo_home) { + Ok(metadata) if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() => {} + Ok(_) => anyhow::bail!( + "bwrap placeholder root {} is not a real directory", + devo_home.display() + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + std::fs::create_dir_all(&devo_home).with_context(|| { + format!( + "could not create bwrap placeholder root {}", + devo_home.display() + ) + })?; + let metadata = std::fs::symlink_metadata(&devo_home).with_context(|| { + format!( + "could not inspect bwrap placeholder root {}", + devo_home.display() + ) + })?; + if !metadata.file_type().is_dir() || metadata.file_type().is_symlink() { + anyhow::bail!( + "bwrap placeholder root {} is not a real directory", + devo_home.display() + ); + } + } + Err(error) => { + return Err(error).with_context(|| { + format!( + "could not inspect bwrap placeholder root {}", + devo_home.display() + ) + }); + } + } + + let template = devo_home.join("bwrap-placeholder.XXXXXX"); + let mut template_bytes = template.into_os_string().into_vec(); + if template_bytes.contains(&0) { + anyhow::bail!("bwrap placeholder template contains an interior NUL"); + } + template_bytes.push(0); + + // SAFETY: `template_bytes` is mutable, NUL-terminated, and ends in six Xs. + if unsafe { libc::mkdtemp(template_bytes.as_mut_ptr().cast()) }.is_null() { + return Err(std::io::Error::last_os_error()) + .context("could not create private bwrap placeholder directory"); + } + template_bytes.pop(); + let directory = PathBuf::from(std::ffi::OsString::from_vec(template_bytes)); + + let mut permissions = std::fs::metadata(&directory) + .with_context(|| { + format!( + "could not inspect private bwrap placeholder directory {}", + directory.display() + ) + })? + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&directory, permissions).with_context(|| { + format!( + "could not secure private bwrap placeholder directory {}", + directory.display() + ) + })?; + if !is_private_bwrap_placeholder_directory(&directory) { + anyhow::bail!( + "bwrap placeholder directory {} failed validation", + directory.display() + ); + } + + Ok(directory) +} + +#[cfg(all(feature = "enforce", target_os = "linux"))] +fn is_private_bwrap_placeholder_directory(directory: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + + let Ok(metadata) = std::fs::symlink_metadata(directory) else { + return false; + }; + metadata.file_type().is_dir() + && !metadata.file_type().is_symlink() + && metadata.permissions().mode() & 0o077 == 0 +} + +#[cfg(all(feature = "enforce", target_os = "linux"))] +fn bwrap_blocked_placeholder_in( + directory: &Path, + kind: BwrapPlaceholderKind, +) -> anyhow::Result { + use std::fs::OpenOptions; + use std::os::unix::fs::OpenOptionsExt; + + if !is_private_bwrap_placeholder_directory(directory) { + anyhow::bail!( + "bwrap placeholder directory {} is not private", + directory.display() + ); + } + + let path = directory.join(kind.name()); + match kind { + BwrapPlaceholderKind::File => { + OpenOptions::new() + .create_new(true) + .custom_flags(libc::O_NOFOLLOW) + .write(true) + .open(&path) + .with_context(|| { + format!("could not create bwrap placeholder {}", path.display()) + })?; + } + BwrapPlaceholderKind::Directory => { + std::fs::create_dir(&path).with_context(|| { + format!("could not create bwrap placeholder {}", path.display()) + })?; + } + } + chmod_000(&path)?; + if !is_valid_bwrap_placeholder(&path, kind) { + anyhow::bail!("bwrap placeholder {} failed validation", path.display()); + } + Ok(path) +} + +#[cfg(all(feature = "enforce", target_os = "linux"))] +fn is_valid_bwrap_placeholder(path: &Path, kind: BwrapPlaceholderKind) -> bool { + use std::os::unix::fs::PermissionsExt; + + let Ok(metadata) = std::fs::symlink_metadata(path) else { + return false; + }; + !metadata.file_type().is_symlink() + && kind.is_expected_type(metadata.file_type()) + && metadata.permissions().mode() & 0o777 == 0 +} + +/// Whether kernel read-deny enforcement is required. Configuration errors are +/// returned rather than being converted into an unsandboxed fallback. +#[cfg(all(feature = "enforce", unix))] +pub fn requires_read_deny(profile: &ProfileName, workspace: &Path) -> anyhow::Result { + match profile { + ProfileName::Custom(name) => { + let config = profiles::load_sandbox_config(workspace)?; + Ok(config + .profiles + .get(name) + .is_some_and(|profile| !profile.deny.is_empty())) + } + _ => Ok(false), + } +} + +/// Stub when `enforce` is unavailable — nothing is kernel-enforced. +#[cfg(not(all(feature = "enforce", unix)))] +pub fn requires_read_deny(_profile: &ProfileName, _workspace: &Path) -> anyhow::Result { + Ok(false) +} + +/// A profile's resolved bwrap deny plan. +#[cfg(target_os = "linux")] +struct BwrapDenyPlan { + deny_write: Vec, + deny_read: Vec, + has_globs: bool, +} + +/// Resolve the bwrap deny plan in one checked config read. +#[cfg(all(feature = "enforce", target_os = "linux"))] +fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> anyhow::Result { + let config = profiles::load_sandbox_config(workspace)?; + let deny_write: Vec = if is_devbox_based(profile, &config) { + vec!["/data".to_string()] + } else { + Vec::new() + }; + let entries = if *profile == ProfileName::Off { + Vec::new() + } else { + profile.resolve_profile(workspace, &config)?.deny + }; + let (exact, globs) = deny::partition_deny_entries(&entries); + let mut deny_read = deny::exact_deny_path_strings(workspace, &exact); + let has_globs = !globs.is_empty(); + if has_globs { + tracing::warn!( + count = globs.len(), + "sandbox deny globs are enforced best-effort on Linux (expanded at launch); \ + files matching them that are created later are NOT covered" + ); + let expanded = deny::expand_deny_globs( + workspace, + &globs, + deny::DENY_GLOB_MAX_DEPTH, + deny::DENY_GLOB_MAX_MATCHES, + deny::DENY_GLOB_MAX_ENTRIES, + ) + .ok_or_else(|| anyhow::anyhow!("sandbox deny glob expansion exceeded safety limits"))?; + deny_read.extend(expanded); + } + Ok(BwrapDenyPlan { + deny_write, + deny_read, + has_globs, + }) +} + +/// Without kernel enforcement, preserve the devbox `/data` write-deny mount. +#[cfg(all(not(feature = "enforce"), target_os = "linux"))] +fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> anyhow::Result { + let config = profiles::load_sandbox_config(workspace)?; + let deny_write = if is_devbox_based(profile, &config) { + vec!["/data".to_string()] + } else { + Vec::new() + }; + Ok(BwrapDenyPlan { + deny_write, + deny_read: Vec::new(), + has_globs: false, + }) +} + +#[cfg(target_os = "linux")] +fn is_devbox_based(profile: &ProfileName, config: &SandboxConfig) -> bool { + match profile { + ProfileName::Devbox => true, + ProfileName::Custom(name) => { + config.profiles.get(name).and_then(|p| p.extends.as_deref()) == Some("devbox") + } + _ => false, + } +} + +/// Build the bwrap re-exec command needed on Linux. It returns `Ok(None)` only +/// if no mount-namespace enforcement is needed or we are already inside bwrap; +/// configuration, glob-expansion, and placeholder failures are errors. +#[cfg(target_os = "linux")] +pub fn bwrap_reexec_for_profile( + profile: &ProfileName, + workspace: &Path, +) -> anyhow::Result> { + bwrap_reexec_for_profile_with_state(profile, workspace, is_inside_bwrap()) +} + +#[cfg(target_os = "linux")] +fn bwrap_reexec_for_profile_with_state( + profile: &ProfileName, + workspace: &Path, + inside_bwrap: bool, +) -> anyhow::Result> { + let BwrapDenyPlan { + deny_write, + deny_read, + has_globs, + } = bwrap_deny_plan(profile, workspace)?; + if deny_write.is_empty() && deny_read.is_empty() && !has_globs { + return Ok(None); + } + let write_refs: Vec<&str> = deny_write.iter().map(String::as_str).collect(); + let read_refs: Vec<&str> = deny_read.iter().map(String::as_str).collect(); + bwrap_reexec_command_with_state(&write_refs, &read_refs, inside_bwrap) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + #[cfg(all(feature = "enforce", unix))] + use std::path::PathBuf; + + #[test] + fn bwrap_reexec_returns_none_inside_bwrap() { + let result = bwrap_reexec_command_with_state(&["/data"], &[], /*inside_bwrap*/ true) + .expect("build bwrap command"); + assert!(result.is_none()); + } + + #[test] + fn bwrap_reexec_returns_some_outside_bwrap() { + let cmd = bwrap_reexec_command_with_state(&["/tmp"], &[], /*inside_bwrap*/ false) + .expect("build bwrap command") + .expect("bwrap command outside bwrap"); + assert_eq!(cmd.get_program(), "bwrap"); + } + + #[test] + fn bwrap_reexec_writes_the_exact_marker_value() { + let cmd = bwrap_reexec_command_with_state(&[], &[], /*inside_bwrap*/ false) + .expect("build bwrap command") + .expect("bwrap command outside bwrap"); + let marker = cmd.get_envs().find_map(|(name, value)| { + if name == std::ffi::OsStr::new(BWRAP_ENV_VAR) { + value + } else { + None + } + }); + + assert_eq!(marker, Some(std::ffi::OsStr::new(BWRAP_MARKER_VALUE))); + } + + #[test] + fn bwrap_marker_requires_the_exact_value_without_mutating_environment() { + assert!(!has_exact_bwrap_marker(/*marker*/ None)); + assert!(!has_exact_bwrap_marker(Some(std::ffi::OsStr::new("")))); + assert!(!has_exact_bwrap_marker(Some(std::ffi::OsStr::new("0")))); + assert!(!has_exact_bwrap_marker(Some(std::ffi::OsStr::new("wrong")))); + assert!(has_exact_bwrap_marker(Some(std::ffi::OsStr::new( + BWRAP_MARKER_VALUE + )))); + } + + #[test] + fn bwrap_marker_requires_affirmative_mount_namespace_isolation() { + let marker = Some(std::ffi::OsStr::new(BWRAP_MARKER_VALUE)); + assert!(!has_bwrap_marker_and_mount_namespace( + marker, + MountNamespaceIsolation::Unknown + )); + assert!(!has_bwrap_marker_and_mount_namespace( + marker, + MountNamespaceIsolation::SharedWithPidOne + )); + assert!(has_bwrap_marker_and_mount_namespace( + marker, + MountNamespaceIsolation::Isolated + )); + assert!(!has_bwrap_marker_and_mount_namespace( + Some(std::ffi::OsStr::new("wrong")), + MountNamespaceIsolation::Isolated + )); + } + + #[test] + fn bwrap_reexec_skips_nonexistent_paths() { + let cmd = bwrap_reexec_command_with_state( + &["/nonexistent-test-path-xyz-12345"], + &[], + /*inside_bwrap*/ false, + ) + .expect("build bwrap command") + .expect("bwrap command outside bwrap"); + let args: Vec = cmd + .get_args() + .map(|arg| arg.to_string_lossy().to_string()) + .collect(); + assert!( + !args + .iter() + .any(|arg| arg == "/nonexistent-test-path-xyz-12345") + ); + } + + #[test] + fn bwrap_reexec_mounts_existing_paths_read_only() { + let cmd = bwrap_reexec_command_with_state(&["/tmp"], &[], /*inside_bwrap*/ false) + .expect("build bwrap command") + .expect("bwrap command outside bwrap"); + let args: Vec = cmd + .get_args() + .map(|arg| arg.to_string_lossy().to_string()) + .collect(); + assert!( + args.windows(3) + .any(|window| window == ["--ro-bind", "/tmp", "/tmp"]) + ); + } + + #[test] + fn bwrap_reexec_uses_dev_bind() { + let cmd = bwrap_reexec_command_with_state(&[], &[], /*inside_bwrap*/ false) + .expect("build bwrap command") + .expect("bwrap command outside bwrap"); + let args: Vec = cmd + .get_args() + .map(|arg| arg.to_string_lossy().to_string()) + .collect(); + assert!( + args.windows(3) + .any(|window| window == ["--dev-bind", "/dev", "/dev"]) + ); + } + + #[cfg(all(feature = "enforce", unix))] + fn temp_workspace_with_sandbox_toml(tag: &str, toml_body: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock after Unix epoch") + .as_nanos(); + let workspace = + std::env::temp_dir().join(format!("devo-{tag}-{}-{nanos}", std::process::id())); + let devo = workspace.join(".devo"); + std::fs::create_dir_all(&devo).expect("create sandbox config directory"); + std::fs::write(devo.join("sandbox.toml"), toml_body).expect("write sandbox config"); + workspace + } + + #[cfg(all(feature = "enforce", unix))] + fn temp_workspace_with_deny(tag: &str, deny_toml: &str) -> PathBuf { + temp_workspace_with_sandbox_toml( + tag, + &format!("[profiles.denytest]\nextends = \"workspace\"\ndeny = [{deny_toml}]\n"), + ) + } + + #[test] + #[cfg(all(feature = "enforce", unix))] + fn requires_read_deny_only_for_custom_profile_with_deny() { + let workspace = temp_workspace_with_deny("requires-deny", "\".env\""); + assert!( + requires_read_deny(&ProfileName::Custom("denytest".to_string()), &workspace) + .expect("load sandbox config") + ); + assert!( + !requires_read_deny(&ProfileName::Custom("undefined".to_string()), &workspace) + .expect("load sandbox config") + ); + assert!(!requires_read_deny(&ProfileName::Workspace, &workspace).expect("load config")); + assert!(!requires_read_deny(&ProfileName::Strict, &workspace).expect("load config")); + assert!(!requires_read_deny(&ProfileName::Devbox, &workspace).expect("load config")); + assert!(!requires_read_deny(&ProfileName::Off, &workspace).expect("load config")); + let _ = std::fs::remove_dir_all(&workspace); + } + + #[test] + #[cfg(all(feature = "enforce", target_os = "linux"))] + fn bwrap_reexec_binds_nonexistent_deny_read_paths() { + let missing = "/nonexistent-deny-read-path-xyz-12345"; + let cmd = bwrap_reexec_command_with_state(&[], &[missing], /*inside_bwrap*/ false) + .expect("build bwrap command") + .expect("bwrap command outside bwrap"); + let args: Vec = cmd + .get_args() + .map(|arg| arg.to_string_lossy().to_string()) + .collect(); + assert!( + args.windows(3) + .any(|window| window[0] == "--ro-bind" && window[2] == missing) + ); + } + + #[test] + #[cfg(all(feature = "enforce", target_os = "linux"))] + fn bwrap_placeholder_is_exclusive_and_validated() { + let directory = private_placeholder_test_directory("valid"); + let placeholder = bwrap_blocked_placeholder_in(&directory, BwrapPlaceholderKind::File) + .expect("create placeholder exclusively"); + assert!(is_valid_bwrap_placeholder( + &placeholder, + BwrapPlaceholderKind::File + )); + assert!(bwrap_blocked_placeholder_in(&directory, BwrapPlaceholderKind::File).is_err()); + let _ = std::fs::remove_dir_all(&directory); + } + + #[test] + #[cfg(all(feature = "enforce", target_os = "linux"))] + fn bwrap_placeholder_rejects_a_preexisting_symlink() { + use std::os::unix::fs::symlink; + + let directory = private_placeholder_test_directory("symlink"); + let target = directory.join("outside-target"); + std::fs::write(&target, "must remain unchanged").expect("write symlink target"); + let placeholder = directory.join(BwrapPlaceholderKind::File.name()); + symlink(&target, &placeholder).expect("create preexisting symlink"); + assert!(bwrap_blocked_placeholder_in(&directory, BwrapPlaceholderKind::File).is_err()); + assert_eq!( + std::fs::read_to_string(&target).expect("read symlink target"), + "must remain unchanged" + ); + let _ = std::fs::remove_dir_all(&directory); + } + + #[cfg(all(feature = "enforce", target_os = "linux"))] + fn private_placeholder_test_directory(tag: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock after Unix epoch") + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "devo-bwrap-placeholder-{tag}-{}-{nanos}", + std::process::id() + )); + std::fs::create_dir(&directory).expect("create placeholder test directory"); + std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700)) + .expect("make placeholder test directory private"); + directory + } + + #[test] + #[cfg(all(feature = "enforce", target_os = "linux"))] + fn bwrap_reexec_for_profile_devbox_extends_composes_data_and_read_deny() { + let workspace = temp_workspace_with_sandbox_toml( + "devbox-compose", + "[profiles.devcustom]\nextends = \"devbox\"\ndeny = [\"secret.pem\"]\n", + ); + let cmd = bwrap_reexec_for_profile_with_state( + &ProfileName::Custom("devcustom".to_string()), + &workspace, + /*inside_bwrap*/ false, + ) + .expect("load sandbox config") + .expect("build bwrap re-exec command"); + let args: Vec = cmd + .get_args() + .map(|arg| arg.to_string_lossy().to_string()) + .collect(); + let deny_path = workspace.join("secret.pem").to_string_lossy().to_string(); + assert!( + args.windows(3) + .any(|window| window[0] == "--ro-bind" && window[2] == deny_path) + ); + if Path::new("/data").exists() { + assert!( + args.windows(3) + .any(|window| window == ["--ro-bind", "/data", "/data"]) + ); + } + let _ = std::fs::remove_dir_all(&workspace); + } +} diff --git a/crates/sandbox/src/child_net.rs b/crates/sandbox/src/child_net.rs new file mode 100644 index 00000000..01ef58e2 --- /dev/null +++ b/crates/sandbox/src/child_net.rs @@ -0,0 +1,445 @@ +//! Per-child seccomp network filter. No-op on non-Linux. + +#[cfg(any(target_os = "linux", test))] +const X32_SYSCALL_BIT: u32 = 0x4000_0000; +#[cfg(any(target_os = "linux", test))] +const I386_SOCKETCALL_SYSCALL: u32 = 102; + +#[cfg(any(target_os = "linux", test))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] +enum SyscallArchitecture { + X86, + X86_64, + Other, +} + +#[cfg(any(target_os = "linux", test))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AlternateSyscallPolicy { + None, + RejectNumberBit(u32), + RejectSyscall(u32), +} + +#[cfg(any(target_os = "linux", test))] +const fn alternate_syscall_policy(architecture: SyscallArchitecture) -> AlternateSyscallPolicy { + match architecture { + SyscallArchitecture::X86 => AlternateSyscallPolicy::RejectSyscall(I386_SOCKETCALL_SYSCALL), + SyscallArchitecture::X86_64 => AlternateSyscallPolicy::RejectNumberBit(X32_SYSCALL_BIT), + SyscallArchitecture::Other => AlternateSyscallPolicy::None, + } +} + +#[cfg(all( + target_os = "linux", + any( + target_arch = "aarch64", + target_arch = "arm", + target_arch = "riscv64", + target_arch = "x86", + target_arch = "x86_64" + ) +))] +const fn native_alternate_syscall_policy() -> AlternateSyscallPolicy { + #[cfg(target_arch = "x86")] + { + alternate_syscall_policy(SyscallArchitecture::X86) + } + #[cfg(target_arch = "x86_64")] + { + alternate_syscall_policy(SyscallArchitecture::X86_64) + } + #[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))] + { + alternate_syscall_policy(SyscallArchitecture::Other) + } +} + +#[cfg(all( + target_os = "linux", + any( + target_arch = "aarch64", + target_arch = "arm", + target_arch = "riscv64", + target_arch = "x86", + target_arch = "x86_64" + ) +))] +const NATIVE_AUDIT_ARCH: u32 = { + #[cfg(target_arch = "aarch64")] + { + 0xc000_00b7 // AUDIT_ARCH_AARCH64 + } + #[cfg(target_arch = "arm")] + { + 0x4000_0028 // AUDIT_ARCH_ARM + } + #[cfg(target_arch = "riscv64")] + { + 0xc000_00f3 // AUDIT_ARCH_RISCV64 + } + #[cfg(target_arch = "x86")] + { + 0x4000_0003 // AUDIT_ARCH_I386 + } + #[cfg(target_arch = "x86_64")] + { + 0xc000_003e // AUDIT_ARCH_X86_64 (including x32) + } +}; + +#[cfg(all( + target_os = "linux", + any( + target_arch = "aarch64", + target_arch = "arm", + target_arch = "riscv64", + target_arch = "x86", + target_arch = "x86_64" + ) +))] +const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000; +#[cfg(all( + target_os = "linux", + any( + target_arch = "aarch64", + target_arch = "arm", + target_arch = "riscv64", + target_arch = "x86", + target_arch = "x86_64" + ) +))] +const SECCOMP_RET_ERRNO: u32 = 0x0005_0000; +#[cfg(all( + target_os = "linux", + any( + target_arch = "aarch64", + target_arch = "arm", + target_arch = "riscv64", + target_arch = "x86", + target_arch = "x86_64" + ) +))] +const EPERM_VAL: u32 = 1; // libc::EPERM +#[cfg(all( + target_os = "linux", + any( + target_arch = "aarch64", + target_arch = "arm", + target_arch = "riscv64", + target_arch = "x86", + target_arch = "x86_64" + ) +))] +const SYSCALL_NUMBER_OFFSET: u32 = 0; // seccomp_data.nr offset +#[cfg(all( + target_os = "linux", + any( + target_arch = "aarch64", + target_arch = "arm", + target_arch = "riscv64", + target_arch = "x86", + target_arch = "x86_64" + ) +))] +const ARCHITECTURE_OFFSET: u32 = 4; // seccomp_data.arch offset + +/// Build the seccomp program used for children which must not access the network. +/// +/// The architecture check must happen before interpreting `seccomp_data.nr`: +/// syscall numbers are architecture-specific, so dispatching them first could +/// accidentally allow a network syscall under a different ABI. +#[cfg(all( + target_os = "linux", + any( + target_arch = "aarch64", + target_arch = "arm", + target_arch = "riscv64", + target_arch = "x86", + target_arch = "x86_64" + ) +))] +fn child_network_filter() -> Vec { + use libc::{ + BPF_ABS, BPF_JEQ, BPF_JMP, BPF_JSET, BPF_K, BPF_LD, BPF_RET, BPF_W, SYS_accept, + SYS_accept4, SYS_bind, SYS_connect, SYS_io_uring_enter, SYS_io_uring_register, + SYS_io_uring_setup, SYS_listen, SYS_sendmmsg, SYS_sendmsg, SYS_sendto, sock_filter, + }; + + macro_rules! bpf_stmt { + ($code:expr, $k:expr) => { + sock_filter { + code: $code as u16, + jt: 0, + jf: 0, + k: $k as u32, + } + }; + } + + macro_rules! bpf_jump { + ($code:expr, $k:expr, $jt:expr, $jf:expr) => { + sock_filter { + code: $code as u16, + jt: $jt, + jf: $jf, + k: $k as u32, + } + }; + } + + // io_uring can submit network operations without invoking a conventional + // network syscall after setup, so block the whole interface conservatively. + let mut blocked_syscalls = vec![ + SYS_connect as u32, + SYS_bind as u32, + SYS_sendto as u32, + SYS_sendmsg as u32, + SYS_sendmmsg as u32, + SYS_listen as u32, + SYS_accept as u32, + SYS_accept4 as u32, + SYS_io_uring_setup as u32, + SYS_io_uring_enter as u32, + SYS_io_uring_register as u32, + ]; + let alternate_policy = native_alternate_syscall_policy(); + if let AlternateSyscallPolicy::RejectSyscall(syscall) = alternate_policy { + #[cfg(target_arch = "x86")] + debug_assert_eq!(syscall, libc::SYS_socketcall as u32); + blocked_syscalls.push(syscall); + } + + let mut filter = Vec::with_capacity(blocked_syscalls.len() + 7); + + // Reject an ABI we did not build this syscall-number policy for. + filter.push(bpf_stmt!(BPF_LD | BPF_W | BPF_ABS, ARCHITECTURE_OFFSET)); + filter.push(bpf_jump!( + BPF_JMP | BPF_JEQ | BPF_K, + NATIVE_AUDIT_ARCH, + 1, + 0 + )); + filter.push(bpf_stmt!(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL)); + + filter.push(bpf_stmt!(BPF_LD | BPF_W | BPF_ABS, SYSCALL_NUMBER_OFFSET)); + if let AlternateSyscallPolicy::RejectNumberBit(bit) = alternate_policy { + filter.push(bpf_jump!( + BPF_JMP | BPF_JSET | BPF_K, + bit, + blocked_syscalls.len() as u8 + 1, // bit set: jump to ERRNO + 0 // bit clear: check direct syscalls + )); + } + for (index, &syscall) in blocked_syscalls.iter().enumerate() { + let remaining_checks = blocked_syscalls.len() - index - 1; + filter.push(bpf_jump!( + BPF_JMP | BPF_JEQ | BPF_K, + syscall, + remaining_checks as u8 + 1, // match: jump to ERRNO + 0 // no match: check next + )); + } + filter.push(bpf_stmt!(BPF_RET | BPF_K, SECCOMP_RET_ALLOW)); + filter.push(bpf_stmt!(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL)); + + filter +} + +#[cfg(test)] +mod structural_policy_tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn x86_64_policy_rejects_x32_syscall_numbers() { + assert_eq!( + alternate_syscall_policy(SyscallArchitecture::X86_64), + AlternateSyscallPolicy::RejectNumberBit(X32_SYSCALL_BIT) + ); + } + + #[test] + fn x86_policy_rejects_legacy_socketcall() { + assert_eq!( + alternate_syscall_policy(SyscallArchitecture::X86), + AlternateSyscallPolicy::RejectSyscall(I386_SOCKETCALL_SYSCALL) + ); + } + + #[test] + fn other_architectures_have_no_alternate_syscall_abi() { + assert_eq!( + alternate_syscall_policy(SyscallArchitecture::Other), + AlternateSyscallPolicy::None + ); + } +} + +/// Install seccomp BPF filter blocking network syscalls. +/// +/// # Safety +/// +/// Must be called in a `pre_exec` context (after `fork`, before `exec`). +#[cfg(all( + target_os = "linux", + any( + target_arch = "aarch64", + target_arch = "arm", + target_arch = "riscv64", + target_arch = "x86", + target_arch = "x86_64" + ) +))] +pub unsafe fn install_child_network_filter() -> std::io::Result<()> { + use libc::{PR_SET_NO_NEW_PRIVS, PR_SET_SECCOMP, SECCOMP_MODE_FILTER, prctl, sock_fprog}; + + let mut filter = child_network_filter(); + let prog = sock_fprog { + len: filter.len() as u16, + filter: filter.as_mut_ptr(), + }; + + // Must set PR_SET_NO_NEW_PRIVS before applying seccomp filter. + // SAFETY: prctl with PR_SET_NO_NEW_PRIVS is safe in pre_exec context. + if unsafe { prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 { + return Err(std::io::Error::last_os_error()); + } + + // SAFETY: prog is a valid sock_fprog pointing to our filter array. + if unsafe { + prctl( + PR_SET_SECCOMP, + SECCOMP_MODE_FILTER as libc::c_ulong, + &prog as *const _ as libc::c_ulong, + 0, + 0, + ) + } != 0 + { + return Err(std::io::Error::last_os_error()); + } + + Ok(()) +} + +/// Refuse to claim network restriction where this crate has no verified audit +/// architecture value for the target ABI. +/// +/// # Safety +/// +/// Must be called in a `pre_exec` context (after `fork`, before `exec`). +#[cfg(all( + target_os = "linux", + not(any( + target_arch = "aarch64", + target_arch = "arm", + target_arch = "riscv64", + target_arch = "x86", + target_arch = "x86_64" + )) +))] +pub unsafe fn install_child_network_filter() -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "child network seccomp filter does not support this Linux architecture", + )) +} + +/// # Safety +/// +/// No-op on non-Linux. +#[cfg(not(target_os = "linux"))] +pub unsafe fn install_child_network_filter() -> std::io::Result<()> { + Ok(()) +} + +#[cfg(all( + test, + target_os = "linux", + any( + target_arch = "aarch64", + target_arch = "arm", + target_arch = "riscv64", + target_arch = "x86", + target_arch = "x86_64" + ) +))] +mod tests { + use super::*; + use libc::{BPF_ABS, BPF_JEQ, BPF_JMP, BPF_K, BPF_LD, BPF_RET, BPF_W}; + use pretty_assertions::assert_eq; + + #[test] + fn policy_checks_architecture_before_syscall_dispatch() { + let filter = child_network_filter(); + + assert_eq!(filter[0].code, (BPF_LD | BPF_W | BPF_ABS) as u16); + assert_eq!(filter[0].k, ARCHITECTURE_OFFSET); + assert_eq!(filter[1].code, (BPF_JMP | BPF_JEQ | BPF_K) as u16); + assert_eq!(filter[1].k, NATIVE_AUDIT_ARCH); + assert_eq!(filter[1].jt, 1); + assert_eq!(filter[1].jf, 0); + assert_eq!(filter[2].code, (BPF_RET | BPF_K) as u16); + assert_eq!(filter[2].k, SECCOMP_RET_ERRNO | EPERM_VAL); + assert_eq!(filter[3].code, (BPF_LD | BPF_W | BPF_ABS) as u16); + assert_eq!(filter[3].k, SYSCALL_NUMBER_OFFSET); + } + + #[test] + fn policy_blocks_batched_and_io_uring_network_paths() { + let filter = child_network_filter(); + let syscall_checks: Vec = filter + .iter() + .filter(|instruction| { + instruction.code == (BPF_JMP | BPF_JEQ | BPF_K) as u16 + && instruction.k != NATIVE_AUDIT_ARCH + }) + .map(|instruction| instruction.k) + .collect(); + + for syscall in [ + libc::SYS_sendmmsg, + libc::SYS_io_uring_setup, + libc::SYS_io_uring_enter, + libc::SYS_io_uring_register, + ] { + assert!( + syscall_checks.contains(&(syscall as u32)), + "missing syscall policy for {syscall}" + ); + } + + assert_eq!(filter[filter.len() - 2].k, SECCOMP_RET_ALLOW); + assert_eq!(filter[filter.len() - 1].k, SECCOMP_RET_ERRNO | EPERM_VAL); + } + + #[test] + #[cfg(target_arch = "x86_64")] + fn generated_policy_rejects_x32_before_direct_syscall_dispatch() { + use libc::BPF_JSET; + + let filter = child_network_filter(); + + assert_eq!(filter[4].code, (BPF_JMP | BPF_JSET | BPF_K) as u16); + assert_eq!(filter[4].k, X32_SYSCALL_BIT); + assert_eq!(filter[4].jf, 0); + } + + #[test] + #[cfg(target_arch = "x86")] + fn generated_policy_blocks_legacy_socketcall() { + let filter = child_network_filter(); + let syscall_checks: Vec = filter + .iter() + .filter(|instruction| { + instruction.code == (BPF_JMP | BPF_JEQ | BPF_K) as u16 + && instruction.k != NATIVE_AUDIT_ARCH + }) + .map(|instruction| instruction.k) + .collect(); + + assert!(syscall_checks.contains(&(libc::SYS_socketcall as u32))); + } +} diff --git a/crates/sandbox/src/deny/glob.rs b/crates/sandbox/src/deny/glob.rs new file mode 100644 index 00000000..705b1d68 --- /dev/null +++ b/crates/sandbox/src/deny/glob.rs @@ -0,0 +1,845 @@ +//! Glob deny entries: detection, the macOS Seatbelt-regex translation, and the +//! Linux launch-time expansion. A deny entry is a GLOB iff it contains a glob +//! metacharacter; macOS emits an anchored runtime regex (covers files created +//! after launch), Linux expands to concrete existing matches at bwrap launch +//! (best-effort). +//! +//! Parity invariant: `validate_deny_glob` accepts/rejects identically on both +//! platforms, and the accepted subset translates the SAME on both — asserted by +//! the `macos_regex_matches_globset_property` cross-product test. + +#[cfg(all(feature = "enforce", unix))] +use nono::CapabilitySet; +#[cfg(all(feature = "enforce", unix))] +use std::path::{Path, PathBuf}; +// macOS regex translation reuses the parent module's alias + write-deny helpers. +#[cfg(all(feature = "enforce", target_os = "macos"))] +use super::{emit_seatbelt_deny, macos_deny_aliases}; + +/// Whether a raw deny entry is a glob pattern rather than an exact path. True iff +/// it contains a gitignore-style metacharacter (`*`, `?`, `[`). +#[cfg(all(feature = "enforce", unix))] +pub(crate) fn is_glob(entry: &str) -> bool { + entry.contains(['*', '?', '[']) +} + +/// Split a profile's raw deny entries into exact paths (handled by the literal / +/// subpath kernel-deny flow) and glob patterns. Non-glob entries are returned +/// unchanged so their exact-path enforcement is preserved with no regression. +#[cfg(all(feature = "enforce", unix))] +pub(crate) fn partition_deny_entries(deny: &[PathBuf]) -> (Vec, Vec) { + let mut exact = Vec::new(); + let mut globs = Vec::new(); + for entry in deny { + match entry.to_str() { + Some(s) if is_glob(s) => globs.push(s.to_string()), + _ => exact.push(entry.clone()), + } + } + (exact, globs) +} + +/// Split a glob into its literal root directory and the glob tail (from the first +/// component containing a metacharacter onward). Relative globs root at +/// `workspace` (recursive `**` allowed); absolute globs root at their leading +/// non-glob components (e.g. `/home/**/.ssh` -> root `/home`, tail `**/.ssh`). +#[cfg(all(feature = "enforce", unix))] +fn split_glob_root(workspace: &Path, glob: &str) -> (PathBuf, String) { + let Some(abs) = glob.strip_prefix('/') else { + return (workspace.to_path_buf(), glob.to_string()); + }; + let mut root = PathBuf::from("/"); + let mut tail: Vec<&str> = Vec::new(); + let mut in_tail = false; + for comp in abs.split('/') { + if in_tail { + tail.push(comp); + } else if is_glob(comp) { + in_tail = true; + tail.push(comp); + } else if !comp.is_empty() { + root.push(comp); + } + } + (root, tail.join("/")) +} + +/// Validate a deny glob on BOTH platforms so a given pattern is interpreted +/// IDENTICALLY everywhere or rejected everywhere (never silently under-enforced +/// on macOS). Two checks, run before the macOS regex translation and the Linux +/// globset expansion alike: +/// +/// 1. Reject `{`/`}`/`\`: globset honors brace alternation and backslash-escapes, +/// but Seatbelt's runtime regex (sourced from globset's own `.regex()` mis- +/// enforces `**/` for root-level paths, so we hand-roll the regex instead and +/// cannot faithfully reproduce those forms — rejecting them on both platforms +/// keeps the two backends in agreement. A user wanting alternation writes +/// separate deny entries. +/// 2. Compile through `globset` (the Linux matcher) so a malformed glob (`a**b`, +/// unterminated `[`) fails closed identically on both platforms. +#[cfg(all(feature = "enforce", unix))] +pub(crate) fn validate_deny_glob(glob: &str) -> anyhow::Result<()> { + if let Some(c) = glob.chars().find(|&c| matches!(c, '{' | '}' | '\\')) { + anyhow::bail!( + "deny glob {glob:?} uses unsupported metacharacter '{c}' \ + (brace alternation and backslash-escapes are not supported; \ + use separate deny entries)" + ); + } + // `**` must be a whole path component (gitignore semantics). A non-component + // `**` (e.g. `a**b`) would translate to `.*` on macOS but collapse to `*` in + // globset — reject it on both platforms so they never diverge. + for comp in glob.split('/') { + if comp.contains("**") && comp != "**" { + anyhow::bail!( + "deny glob {glob:?}: `**` must be its own path component (got segment {comp:?})" + ); + } + } + // Char classes: support only the simple subset that translates identically to + // globset. Reject a literal `]`-first member (`[]a]`) and any nested `[` — + // which covers POSIX `[[:…:]]` — since globset and the hand-rolled regex parse + // those differently. (A leading `!`/`^` negation IS supported.) + let cc: Vec = glob.chars().collect(); + let mut i = 0; + while i < cc.len() { + if cc[i] != '[' { + i += 1; + continue; + } + let mut j = i + 1; + if matches!(cc.get(j), Some('!') | Some('^')) { + j += 1; + } + if cc.get(j) == Some(&']') { + anyhow::bail!("deny glob {glob:?}: a literal ']' as first class member is unsupported"); + } + while j < cc.len() && cc[j] != ']' { + if cc[j] == '[' { + anyhow::bail!( + "deny glob {glob:?}: nested '[' / POSIX '[[:…:]]' classes are unsupported" + ); + } + j += 1; + } + // Unterminated class: let the globset build below report it uniformly. + i = if j < cc.len() { j + 1 } else { cc.len() }; + } + globset::GlobBuilder::new(glob) + .literal_separator(true) + .build() + .map_err(|e| anyhow::anyhow!("invalid deny glob {glob:?}: {e}"))?; + Ok(()) +} + +/// Push `c` as a regex literal, escaping it when it is a regex metacharacter. +#[cfg(all(feature = "enforce", target_os = "macos"))] +fn push_escaped_regex_literal(out: &mut String, c: char) { + if matches!( + c, + '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '^' | '$' | '|' | '\\' + ) { + out.push('\\'); + } + out.push(c); +} + +/// Regex-escape every character of a literal path segment. +#[cfg(all(feature = "enforce", target_os = "macos"))] +fn escape_regex_literal_str(s: &str) -> String { + let mut out = String::new(); + for c in s.chars() { + push_escaped_regex_literal(&mut out, c); + } + out +} + +/// Translate a gitignore-style glob tail into an (unanchored) Seatbelt regex +/// body. Dialect: `**/`->`(.*/)?`, `**`->`.*`, `*`->`[^/]*`, `?`->`[^/]`, +/// `[...]` classes copied with a leading `!`/`^` -> regex negation `[^…]`, all +/// other literal text regex-escaped. Only the class subset `validate_deny_glob` +/// accepts reaches here, so it always matches globset. +#[cfg(all(feature = "enforce", target_os = "macos"))] +fn glob_tail_to_regex(tail: &str) -> String { + let mut out = String::new(); + let mut chars = tail.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '*' => { + if chars.peek() == Some(&'*') { + chars.next(); + if chars.peek() == Some(&'/') { + chars.next(); + out.push_str("(.*/)?"); // `**/` spans zero or more dirs + } else { + out.push_str(".*"); // `**` spans anything, incl. `/` + } + } else { + out.push_str("[^/]*"); // `*` stops at a path separator + } + } + '?' => out.push_str("[^/]"), + '[' => { + out.push('['); + // globset treats a leading `!` OR `^` as negation -> regex `[^…]` + // (validate_deny_glob has rejected the class forms that would drift). + if matches!(chars.peek(), Some('!') | Some('^')) { + chars.next(); + out.push('^'); + } + while let Some(cc) = chars.next() { + if cc == ']' { + break; + } + // Backslash-escapes are rejected by validate_deny_glob; this + // passthrough stays defensive. + if cc == '\\' { + out.push('\\'); + if let Some(n) = chars.next() { + out.push(n); + } + } else { + out.push(cc); + } + } + out.push(']'); + } + c => push_escaped_regex_literal(&mut out, c), + } + } + out +} + +/// Anchored Seatbelt regex bodies for one glob — one per macOS alias form of the +/// glob's root (workspace for relative, literal prefix for absolute) so the broad +/// read-allow cannot be bypassed via the `/private` firmlink alias. +#[cfg(all(feature = "enforce", target_os = "macos"))] +fn glob_to_seatbelt_regexes(workspace: &Path, glob: &str) -> Vec { + let (root, tail) = split_glob_root(workspace, glob); + let tail_regex = glob_tail_to_regex(&tail); + let canonical_root = dunce::canonicalize(&root).unwrap_or_else(|_| root.clone()); + let mut regexes = Vec::new(); + for form in macos_deny_aliases(&root, &canonical_root) { + let Some(form_str) = form.to_str() else { + continue; + }; + let escaped_root = escape_regex_literal_str(form_str); + // Avoid a double slash when the root is `/`. + let sep = if escaped_root.ends_with('/') { "" } else { "/" }; + regexes.push(format!("^{escaped_root}{sep}{tail_regex}$")); + } + regexes +} + +/// Wrap a finished regex body in a Seatbelt `(regex #"…")` filter, escaping the +/// SBPL string delimiter and rejecting control chars. Fail-closed: returns +/// `None` for an inexpressible pattern so the caller errors rather than emitting +/// a rule that silently targets the wrong path. +#[cfg(all(feature = "enforce", target_os = "macos"))] +fn seatbelt_regex_filter(regex: &str) -> Option { + if regex.chars().any(|c| c.is_control()) { + return None; + } + let escaped = regex.replace('"', "\\\""); + Some(format!("(regex #\"{escaped}\")")) +} + +/// Apply kernel-level deny rules for glob patterns. +/// +/// On macOS, translate each glob to an anchored Seatbelt regex and emit the same +/// read + per-write-sub-action denies as the exact-path flow (so `mv x y && cat y` +/// stays closed), covering files created after launch. On Linux this is a no-op: +/// a mount namespace can't match a regex at runtime, so globs are expanded to +/// concrete paths and bound over at bwrap re-exec (see [`expand_deny_globs`]). +/// +/// Unlike the exact-path flow, this does NOT call `remove_exact_file_caps_for_paths` +/// (a glob can't enumerate the file caps it collides with); glob denies rely on +/// Seatbelt last-match ordering — the deny platform rules are emitted after the +/// read/write allows, so the regex deny wins. The e2e is the contract. +#[cfg(all(feature = "enforce", unix))] +pub(crate) fn apply_deny_globs_to_capability_set( + caps: &mut CapabilitySet, + workspace: &Path, + globs: &[String], +) -> anyhow::Result<()> { + if globs.is_empty() { + return Ok(()); + } + + #[cfg(target_os = "macos")] + { + for glob in globs { + // Fail CLOSED on any glob that isn't expressible identically on both + // platforms (braces/backslash) or is malformed — same check Linux runs. + validate_deny_glob(glob)?; + let regexes = glob_to_seatbelt_regexes(workspace, glob); + if regexes.is_empty() { + // Fail CLOSED: a glob we can't anchor would be silently + // unprotected while the sandbox still reports active. + anyhow::bail!("cannot translate deny glob {glob:?} to a Seatbelt regex"); + } + for regex in regexes { + let Some(filter) = seatbelt_regex_filter(®ex) else { + anyhow::bail!("cannot express deny glob {glob:?} as a Seatbelt regex filter"); + }; + emit_seatbelt_deny(caps, &filter)?; + } + } + tracing::info!( + count = globs.len(), + "Applied Seatbelt deny regexes for sandbox deny globs" + ); + } + + #[cfg(target_os = "linux")] + { + let _ = (caps, workspace); + tracing::debug!( + count = globs.len(), + "Linux deny globs are expanded and bound over at bwrap re-exec (launch-time)" + ); + } + + Ok(()) +} + +/// Caps for launch-time deny-glob expansion on Linux. A mount namespace can't +/// glob at runtime, so globs are expanded to existing matches once at launch; +/// these bounds stop a broad glob (e.g. `**/*`) from exploding the bind list or +/// taking an unbounded walk. Exceeding either fails closed (see [`expand_deny_globs`]). +#[cfg(all(feature = "enforce", target_os = "linux"))] +pub(crate) const DENY_GLOB_MAX_DEPTH: usize = 64; +#[cfg(all(feature = "enforce", target_os = "linux"))] +pub(crate) const DENY_GLOB_MAX_MATCHES: usize = 4096; +/// Total tree entries the walk may visit before failing closed. Bounds launch +/// latency on large repos (`max_matches` caps matches, not entries visited) so a +/// broad glob that matches little still can't walk an unbounded tree each launch. +#[cfg(all(feature = "enforce", target_os = "linux"))] +pub(crate) const DENY_GLOB_MAX_ENTRIES: usize = 200_000; + +/// Classify a walk error hit while expanding deny globs. A permission error means +/// the same-uid agent is equally denied by the kernel, so skipping that subtree +/// exposes nothing; any other error (transient IO, fd exhaustion, a race) could +/// hide a readable match, so it is fatal and we fail closed rather than under-enforce. +#[cfg(all(feature = "enforce", target_os = "linux"))] +fn deny_glob_walk_error_is_fatal(err: &ignore::Error) -> bool { + match err.io_error() { + Some(io) => io.kind() != std::io::ErrorKind::PermissionDenied, + None => true, + } +} + +/// Expand deny GLOBS into the concrete EXISTING paths that match, for the Linux +/// bwrap bind-over. Relative globs anchor at `workspace`; absolute globs at their +/// literal (non-glob) prefix. The walk DISABLES gitignore/hidden filters (a +/// denied secret like `.env` or `*.pem` is usually both) and never follows +/// symlinks (a symlink must not smuggle its target into the deny set). +/// +/// Returns `None` (fail closed) if a glob is invalid, the walk is truncated by +/// `max_depth`, more than `max_entries` are visited, matches exceed `max_matches`, +/// or the walk hits a non-permission error — so the caller refuses to start rather +/// than under-enforcing or exploding the bind list. A permission error is skipped +/// (the same-uid agent is equally OS-denied). Each fail-closed cause is logged so +/// the refusal names the glob (not the generic "install bubblewrap" path). +/// +/// Best-effort: files created AFTER launch that match a glob are NOT covered on +/// Linux. macOS Seatbelt enforces the same globs as runtime regexes, so they are. +#[cfg(all(feature = "enforce", target_os = "linux"))] +pub(crate) fn expand_deny_globs( + workspace: &Path, + globs: &[String], + max_depth: usize, + max_matches: usize, + max_entries: usize, +) -> Option> { + use globset::{GlobBuilder, GlobSetBuilder}; + use ignore::WalkBuilder; + use std::collections::BTreeSet; + + // Log + surface the real reason before fail-closing so the shell's refusal is + // not misattributed to a missing bubblewrap. + let fail = |reason: String| -> Option> { + tracing::error!(%reason, "sandbox deny-glob expansion failed; refusing to start"); + eprintln!("error: sandbox deny glob could not be enforced on Linux: {reason}"); + None + }; + + let ws = workspace.to_string_lossy().into_owned(); + let mut builder = GlobSetBuilder::new(); + let mut roots: BTreeSet = BTreeSet::new(); + for glob in globs { + // Same validation macOS runs, so a malformed/unsupported glob fails closed + // identically on both platforms. + if let Err(e) = validate_deny_glob(glob) { + return fail(e.to_string()); + } + // Match against absolute paths: relative globs get the (escaped) workspace + // prefix; absolute globs are used as-is. `literal_separator(true)` => + // `*`/`?` stop at `/` (gitignore-style), matching the macOS translation. + let pattern = if glob.starts_with('/') { + glob.clone() + } else { + format!("{}/{}", globset::escape(&ws), glob) + }; + let Ok(compiled) = GlobBuilder::new(&pattern).literal_separator(true).build() else { + return fail(format!("could not compile glob {glob:?}")); + }; + builder.add(compiled); + roots.insert(split_glob_root(workspace, glob).0); + } + let Ok(set) = builder.build() else { + return fail("could not build glob set".to_string()); + }; + + let mut matches: BTreeSet = BTreeSet::new(); + let mut visited: usize = 0; + for root in roots { + if !root.exists() { + continue; + } + let walker = WalkBuilder::new(&root) + .max_depth(Some(max_depth)) + .standard_filters(false) + .hidden(false) + .follow_links(false) + .build(); + for dent in walker { + let dent = match dent { + Ok(dent) => dent, + Err(e) => { + // Hidden subtree: skip OS-enforced permission errors, fail closed on anything else. + if deny_glob_walk_error_is_fatal(&e) { + return fail(format!("walk error under a deny-glob root: {e}")); + } + tracing::warn!(error = %e, "skipping unreadable entry during deny-glob walk"); + continue; + } + }; + visited += 1; + if visited > max_entries { + return fail(format!( + "walk visited over {max_entries} entries (glob too broad)" + )); + } + // A directory at the depth cap may hide deeper matches we cannot see; + // fail closed rather than silently under-enforce. + if dent.depth() >= max_depth && dent.file_type().is_some_and(|ft| ft.is_dir()) { + return fail(format!("tree deeper than the {max_depth}-level depth cap")); + } + let path = dent.path(); + if set.is_match(path) { + // A non-UTF8 match can't be bound by a string path. Fail closed + // (like the exact-path Seatbelt flow) rather than skip it and leave + // a matching secret readable while the sandbox reports active. + let Some(s) = path.to_str() else { + return fail(format!("deny-glob match has a non-UTF8 path: {path:?}")); + }; + matches.insert(s.to_owned()); + if matches.len() > max_matches { + return fail(format!("matched over {max_matches} files (glob too broad)")); + } + } + } + } + Some(matches.into_iter().collect()) +} + +#[cfg(test)] +mod tests { + // All tests here exercise enforce+unix paths; without the gate `super::*` + // is unused on `--no-default-features`. + #[cfg(all(feature = "enforce", unix))] + use super::*; + #[cfg(all(feature = "enforce", unix))] + use pretty_assertions::assert_eq; + + #[test] + #[cfg(all(feature = "enforce", unix))] + fn is_glob_detects_metacharacters() { + assert!(is_glob("**/.env")); + assert!(is_glob("**/*.pem")); + assert!(is_glob("secrets/**")); + assert!(is_glob("a?b")); + assert!(is_glob("[abc].txt")); + // Exact paths must NOT be treated as globs (no regression in literal deny). + assert!(!is_glob(".env")); + assert!(!is_glob("src/server.pem")); + assert!(!is_glob("/etc/shadow")); + } + + #[test] + #[cfg(all(feature = "enforce", unix))] + fn partition_separates_globs_from_exact_paths() { + let deny = vec![ + PathBuf::from(".env"), + PathBuf::from("**/*.pem"), + PathBuf::from("/etc/shadow"), + PathBuf::from("secrets/**"), + ]; + let (exact, globs) = partition_deny_entries(&deny); + assert_eq!( + exact, + vec![PathBuf::from(".env"), PathBuf::from("/etc/shadow")] + ); + assert_eq!( + globs, + vec!["**/*.pem".to_string(), "secrets/**".to_string()] + ); + } + + #[test] + #[cfg(all(feature = "enforce", unix))] + fn split_glob_root_relative_vs_absolute() { + let ws = Path::new("/ws"); + assert_eq!( + split_glob_root(ws, "**/.env"), + (PathBuf::from("/ws"), "**/.env".to_string()) + ); + assert_eq!( + split_glob_root(ws, "/home/**/.ssh"), + (PathBuf::from("/home"), "**/.ssh".to_string()) + ); + } + + #[test] + #[cfg(all(feature = "enforce", target_os = "macos"))] + fn glob_tail_translates_to_seatbelt_regex() { + assert_eq!(glob_tail_to_regex("**/.env"), "(.*/)?\\.env"); + assert_eq!(glob_tail_to_regex("**/*.pem"), "(.*/)?[^/]*\\.pem"); + assert_eq!(glob_tail_to_regex("secrets/**"), "secrets/.*"); + assert_eq!(glob_tail_to_regex("*.key"), "[^/]*\\.key"); + assert_eq!(glob_tail_to_regex("a?b"), "a[^/]b"); + } + + #[test] + #[cfg(all(feature = "enforce", target_os = "macos"))] + fn glob_tail_translates_char_classes() { + assert_eq!(glob_tail_to_regex("[abc].txt"), "[abc]\\.txt"); + assert_eq!(glob_tail_to_regex("[a-z].rs"), "[a-z]\\.rs"); + // A leading `!` OR `^` is NEGATION in globset -> regex `[^…]` (both must + // produce the SAME negated class, else macOS under-matches). + assert_eq!(glob_tail_to_regex("[!a]b"), "[^a]b"); + assert_eq!(glob_tail_to_regex("[^a]b"), "[^a]b"); + } + + #[test] + #[cfg(all(feature = "enforce", unix))] + fn validate_deny_glob_accepts_subset_rejects_rest() { + // Supported subset (`*`, `?`, `**`, `[...]` incl. `[!a]`/`[^a]` negation). + for g in [ + "**/*.pem", + "**/.env", + "secrets/**", + "[abc].txt", + "[a-z].rs", + "[!a]b", + "[^a]b", + "a?b", + "/home/**/.ssh", + ] { + assert!(validate_deny_glob(g).is_ok(), "{g} should be supported"); + } + // Braces + backslash drift macOS vs globset -> rejected (fail closed) on BOTH. + for g in ["**/*.{pem,key}", "a\\*b", "{a,b}"] { + assert!(validate_deny_glob(g).is_err(), "{g} should be rejected"); + } + // Char-class forms that parse differently in globset vs the regex engine. + for g in ["[]a]", "[[:alpha:]]", "[a[b]"] { + assert!( + validate_deny_glob(g).is_err(), + "{g} unsupported char class should be rejected" + ); + } + // Malformed globs fail closed identically to the Linux globset backend. + for g in ["a**b", "**a", "[abc"] { + assert!( + validate_deny_glob(g).is_err(), + "{g} should be rejected as malformed" + ); + } + } + + #[test] + #[cfg(all(feature = "enforce", target_os = "macos"))] + fn glob_to_regex_doubles_private_aliased_root() { + // A workspace under /tmp (firmlinked to /private/tmp) must emit a deny + // regex for BOTH alias roots, else the broad read-allow leaks via the alias. + let regexes = glob_to_seatbelt_regexes(Path::new("/tmp/projalias"), "**/.env"); + assert!( + regexes.contains(&"^/tmp/projalias/(.*/)?\\.env$".to_string()), + "{regexes:?}" + ); + assert!( + regexes.contains(&"^/private/tmp/projalias/(.*/)?\\.env$".to_string()), + "{regexes:?}" + ); + } + + #[test] + #[cfg(all(feature = "enforce", target_os = "macos"))] + fn macos_regex_matches_globset_property() { + // PARITY GUARD (cross-product): for EVERY pattern `validate_deny_glob` + // accepts, the hand-rolled macOS regex must match a path IFF globset (the + // Linux backend) matches it. Generated from building blocks (literals, + // regex-metachar literal, `*`, `?`, `**`, char classes incl. both + // negations, ranges, class-content edge cases, and a `]` outside a class) + // crossed with sample paths, so any future dialect drift fails + // mechanically. Rejected forms are asserted to fail closed. + let segs = [ + "a", "x.y", "*", "?", "**", "[abc]", "[a-z]", "[!a]", "[^a]", "[.]", "[*]", "[a^]", + "[a-]", "[-a]", "*]", + ]; + let paths = [ + "a", + "ab", + "x", + "x.y", + "xay", + "^", + "!", + ".", + "-", + "*", + "b", + "a/b", + "ab/cd", + "sub/a", + "sub/dir/a", + ".env", + "sub/.env", + "k.pem", + "foo.env", + ".envrc", + "secrets/x", + "]", + "a]", + ]; + // Build single- and two-segment patterns from the blocks. + let mut patterns: Vec = Vec::new(); + for a in segs { + patterns.push(a.to_string()); + for b in segs { + patterns.push(format!("{a}/{b}")); + } + } + for p in &patterns { + if validate_deny_glob(p).is_err() { + continue; // rejected patterns aren't enforced on either platform + } + let regexes = glob_to_seatbelt_regexes(Path::new("/ws"), p); + assert_eq!(regexes.len(), 1, "expected one regex for {p:?}"); + let re = regex::Regex::new(®exes[0]).unwrap_or_else(|e| panic!("{p:?}: {e}")); + let gs = globset::GlobBuilder::new(&format!("/ws/{p}")) + .literal_separator(true) + .build() + .unwrap() + .compile_matcher(); + for path in paths { + let abs = format!("/ws/{path}"); + assert_eq!( + re.is_match(&abs), + gs.is_match(&abs), + "DRIFT for pattern {p:?} on {abs:?}: macos={}, globset={}", + re.is_match(&abs), + gs.is_match(&abs) + ); + } + } + // Forms that MUST fail closed on both platforms (cannot translate identically). + for bad in [ + "[]a]", + "[[:alpha:]]", + "{a,b}", + "**/*.{pem,key}", + "a**b", + "**a", + "[abc", + ] { + assert!(validate_deny_glob(bad).is_err(), "{bad} must be rejected"); + } + } + + #[test] + #[cfg(all(feature = "enforce", target_os = "macos"))] + fn glob_to_regex_anchors_relative_at_workspace() { + // A non-existent workspace cannot canonicalize/alias, so exactly one + // anchored regex is produced. + let regexes = glob_to_seatbelt_regexes(Path::new("/ws-does-not-exist-xyz"), "**/*.pem"); + assert_eq!( + regexes, + vec!["^/ws-does-not-exist-xyz/(.*/)?[^/]*\\.pem$".to_string()] + ); + } + + #[test] + #[cfg(all(feature = "enforce", target_os = "macos"))] + fn glob_to_regex_roots_absolute_at_literal_prefix() { + let regexes = + glob_to_seatbelt_regexes(Path::new("/ws-does-not-exist-xyz"), "/nope-xyz/**/.ssh"); + assert_eq!(regexes, vec!["^/nope-xyz/(.*/)?\\.ssh$".to_string()]); + } + + #[test] + #[cfg(all(feature = "enforce", target_os = "macos"))] + fn seatbelt_regex_filter_wraps_and_rejects_control_chars() { + assert_eq!( + seatbelt_regex_filter("^/ws/(.*/)?\\.env$").unwrap(), + "(regex #\"^/ws/(.*/)?\\.env$\")" + ); + assert!(seatbelt_regex_filter("a\u{07}b").is_none()); + } + + #[test] + #[cfg(all(feature = "enforce", target_os = "macos"))] + fn apply_deny_globs_emits_nono_accepted_rules() { + // Every emitted `(deny … (regex …))` must pass nono's validate_platform_rule. + let mut caps = CapabilitySet::new(); + let globs = vec![ + "**/*.pem".to_string(), + "**/.env".to_string(), + "secrets/**".to_string(), + ]; + apply_deny_globs_to_capability_set(&mut caps, Path::new("/ws-does-not-exist-xyz"), &globs) + .expect("emitted Seatbelt deny regexes must be accepted by nono"); + } + + // Linux launch-time expansion + its fail-closed caps. Gated to enforce+linux, + // so they run on the Linux CI lane (not on macOS). + #[cfg(all(feature = "enforce", target_os = "linux"))] + mod linux_expand { + use super::*; + + struct TmpTree(PathBuf); + impl Drop for TmpTree { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + fn tmp_tree(tag: &str) -> PathBuf { + let p = std::env::temp_dir().join(format!( + "deny-glob-ut-{tag}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&p).unwrap(); + p + } + + #[test] + fn matches_nested_pem_and_dotenv_excludes_control() { + let ws = tmp_tree("match"); + let _g = TmpTree(ws.clone()); + std::fs::create_dir_all(ws.join("sub/dir")).unwrap(); + std::fs::write(ws.join("sub/dir/key.pem"), "x").unwrap(); + std::fs::write(ws.join(".env"), "x").unwrap(); // hidden + usually gitignored + std::fs::write(ws.join("readable.txt"), "x").unwrap(); + let globs = vec!["**/*.pem".to_string(), "**/.env".to_string()]; + let out = expand_deny_globs(&ws, &globs, 64, 4096, 200_000).expect("should expand"); + assert!( + out.iter().any(|p| p.ends_with("sub/dir/key.pem")), + "{out:?}" + ); + assert!(out.iter().any(|p| p.ends_with("/.env")), "{out:?}"); + assert!(!out.iter().any(|p| p.ends_with("readable.txt")), "{out:?}"); + } + + #[test] + fn empty_when_nothing_matches() { + let ws = tmp_tree("empty"); + let _g = TmpTree(ws.clone()); + std::fs::write(ws.join("a.txt"), "x").unwrap(); + let out = expand_deny_globs(&ws, &["**/*.pem".to_string()], 64, 4096, 200_000).unwrap(); + assert!(out.is_empty(), "{out:?}"); + } + + #[test] + fn fails_closed_on_match_cap() { + let ws = tmp_tree("matchcap"); + let _g = TmpTree(ws.clone()); + for i in 0..5 { + std::fs::write(ws.join(format!("k{i}.pem")), "x").unwrap(); + } + assert!(expand_deny_globs(&ws, &["**/*.pem".to_string()], 64, 2, 200_000).is_none()); + } + + #[test] + fn fails_closed_on_depth_cap() { + let ws = tmp_tree("depthcap"); + let _g = TmpTree(ws.clone()); + std::fs::create_dir_all(ws.join("a/b/c")).unwrap(); + std::fs::write(ws.join("a/b/c/k.pem"), "x").unwrap(); + // A directory sits at the depth cap -> deeper matches could hide -> None. + assert!(expand_deny_globs(&ws, &["**/*.pem".to_string()], 1, 4096, 200_000).is_none()); + } + + #[test] + fn fails_closed_on_entries_cap() { + let ws = tmp_tree("entriescap"); + let _g = TmpTree(ws.clone()); + for i in 0..10 { + std::fs::write(ws.join(format!("f{i}.txt")), "x").unwrap(); + } + // Broad walk, nothing matches, but the entries cap still fails closed. + assert!(expand_deny_globs(&ws, &["**/*.pem".to_string()], 64, 4096, 3).is_none()); + } + + #[test] + fn rejects_unsupported_glob() { + let ws = tmp_tree("reject"); + let _g = TmpTree(ws.clone()); + // Braces are rejected identically to macOS (fail closed). + assert!( + expand_deny_globs(&ws, &["**/*.{pem,key}".to_string()], 64, 4096, 200_000) + .is_none() + ); + } + + #[test] + fn does_not_descend_symlinked_dir() { + let ws = tmp_tree("symlink"); + let _g = TmpTree(ws.clone()); + let outside = tmp_tree("symlink-outside"); + let _g2 = TmpTree(outside.clone()); + std::fs::write(outside.join("secret.pem"), "x").unwrap(); + std::os::unix::fs::symlink(&outside, ws.join("link")).unwrap(); + // follow_links(false): the symlink must not smuggle its target in. + let out = expand_deny_globs(&ws, &["**/*.pem".to_string()], 64, 4096, 200_000).unwrap(); + assert!( + !out.iter().any(|p| p.contains("secret.pem")), + "symlinked dir must not be descended: {out:?}" + ); + } + + #[test] + fn walk_error_permission_skipped_others_fatal() { + use std::io; + // EACCES on a dir: the same-uid agent is equally OS-denied -> skip (non-fatal). + let perm = ignore::Error::from(io::Error::from(io::ErrorKind::PermissionDenied)); + assert!(!deny_glob_walk_error_is_fatal(&perm)); + // A non-permission IO error could hide a readable match -> fatal (fail closed). + let other = ignore::Error::from(io::Error::other("boom")); + assert!(deny_glob_walk_error_is_fatal(&other)); + // A non-IO walk error (e.g. a symlink loop) has no io_error -> fatal. + let loop_err = ignore::Error::Loop { + ancestor: PathBuf::from("/a"), + child: PathBuf::from("/a/b"), + }; + assert!(deny_glob_walk_error_is_fatal(&loop_err)); + } + + #[test] + fn fails_closed_on_non_utf8_match() { + use std::os::unix::ffi::OsStrExt; + let ws = tmp_tree("nonutf8"); + let _g = TmpTree(ws.clone()); + // A filename with an invalid UTF-8 byte that still matches `*.pem`. + let name = std::ffi::OsStr::from_bytes(b"secret\xFF.pem"); + std::fs::write(ws.join(name), "x").unwrap(); + // The match can't be expressed as a UTF-8 bind path -> fail closed (None). + assert!(expand_deny_globs(&ws, &["**/*.pem".to_string()], 64, 4096, 200_000).is_none()); + } + } +} diff --git a/crates/sandbox/src/deny/mod.rs b/crates/sandbox/src/deny/mod.rs new file mode 100644 index 00000000..88a779e2 --- /dev/null +++ b/crates/sandbox/src/deny/mod.rs @@ -0,0 +1,311 @@ +//! Kernel-enforced deny paths for sandbox profiles. +//! +//! macOS: Seatbelt platform rules via [`nono::CapabilitySet::add_platform_rule`]. +//! Linux: Landlock cannot deny a subpath of an allowed tree; read-deny is +//! enforced via bwrap bind-over (see [`crate::bwrap_reexec_command`]). + +#[cfg(all(feature = "enforce", unix))] +use nono::CapabilitySet; +#[cfg(all(feature = "enforce", unix))] +use std::path::{Path, PathBuf}; + +// Glob deny entries (detection, macOS regex translation, Linux launch-time +// expansion) live in a submodule; re-exported so call sites use `deny::…`. +#[cfg(all(feature = "enforce", unix))] +mod glob; +#[cfg(all(feature = "enforce", target_os = "linux"))] +pub(crate) use glob::{ + DENY_GLOB_MAX_DEPTH, DENY_GLOB_MAX_ENTRIES, DENY_GLOB_MAX_MATCHES, expand_deny_globs, +}; +#[cfg(all(feature = "enforce", unix))] +pub(crate) use glob::{apply_deny_globs_to_capability_set, partition_deny_entries}; + +/// Escape a path for use inside a Seatbelt `(literal "...")` / `(subpath "...")` +/// filter (used for both forms, hence the generic name). +#[cfg(all(feature = "enforce", target_os = "macos"))] +fn escape_seatbelt_path(path: &Path) -> Option { + let s = path.to_str()?; + // Reject all control chars (matching nono's escape_path); silently passing + // one through would target a different path than intended. + if s.chars().any(|c| c.is_control()) { + return None; + } + Some(s.replace('\\', "\\\\").replace('"', "\\\"")) +} + +/// All literal paths a deny rule must cover on macOS: the as-given path, its +/// canonical form, and the `/private` firmlink alias of each (e.g. `/tmp/x` <-> +/// `/private/tmp/x`) so a deny cannot be bypassed via an alias. +#[cfg(all(feature = "enforce", target_os = "macos"))] +fn macos_deny_aliases(path: &Path, canonical: &Path) -> Vec { + let mut forms: Vec = vec![path.to_path_buf()]; + if canonical != path { + forms.push(canonical.to_path_buf()); + } + for form in forms.clone() { + if let Some(alias) = toggle_private_prefix(&form) + && !forms.contains(&alias) + { + forms.push(alias); + } + } + forms +} + +/// Toggle the macOS `/private` firmlink prefix for `/tmp`, `/var`, `/etc` +/// (e.g. `/private/tmp/x` <-> `/tmp/x`). Returns `None` for unaffected paths. +#[cfg(all(feature = "enforce", target_os = "macos"))] +fn toggle_private_prefix(path: &Path) -> Option { + let s = path.to_str()?; + for dir in ["tmp", "var", "etc"] { + if let Some(rest) = s.strip_prefix(&format!("/private/{dir}")) + && (rest.is_empty() || rest.starts_with('/')) + { + return Some(PathBuf::from(format!("/{dir}{rest}"))); + } + if let Some(rest) = s.strip_prefix(&format!("/{dir}")) + && (rest.is_empty() || rest.starts_with('/')) + { + return Some(PathBuf::from(format!("/private/{dir}{rest}"))); + } + } + None +} + +/// Specific Seatbelt write sub-actions denied for a denied path. +/// +/// `(deny file-write* ...)` alone does NOT win: nono emits platform rules +/// between the read-allows and the write-allows, so the broad workspace +/// `(allow file-write* (subpath ))` is emitted AFTER our deny and wins by +/// last-match — leaving an in-workspace denied path writable (so `mv x y && cat y` +/// could relocate and read it). Empirically, denying each concrete write +/// sub-action (every one more specific than the `file-write*` grant) makes the +/// deny win regardless of emission order, fully blocking overwrite AND relocation +/// (rename/unlink). This is observed per-operation rule-list behavior, not a +/// guaranteed action-specificity rule — the macOS e2e is the contract. +#[cfg(all(feature = "enforce", target_os = "macos"))] +const SEATBELT_WRITE_DENY_ACTIONS: &[&str] = &[ + "file-write-data", + "file-write-create", + "file-write-unlink", + "file-write-mode", + "file-write-owner", + "file-write-flags", + "file-write-times", + "file-write-setugid", +]; + +/// Emit the full read+write deny rule set for a single Seatbelt `filter` +/// (`(literal ...)` or `(subpath ...)`). See [`SEATBELT_WRITE_DENY_ACTIONS`] for +/// why the specific write sub-actions are required in addition to `file-write*`. +#[cfg(all(feature = "enforce", target_os = "macos"))] +fn emit_seatbelt_deny(caps: &mut CapabilitySet, filter: &str) -> anyhow::Result<()> { + // Read-deny wins via last-match (platform rules are emitted after read-allows). + caps.add_platform_rule(format!("(deny file-read* {filter})"))?; + // Catch-all write-deny (wins for out-of-workspace paths with no competing + // write grant, e.g. ~/.ssh) ... + caps.add_platform_rule(format!("(deny file-write* {filter})"))?; + // ... plus action-specific write denies that also win inside the workspace. + for action in SEATBELT_WRITE_DENY_ACTIONS { + caps.add_platform_rule(format!("(deny {action} {filter})"))?; + } + Ok(()) +} + +/// Apply kernel-level deny rules for the given paths. +/// +/// On macOS, adds Seatbelt read-deny + write-deny (incl. specific write +/// sub-actions) rules. On Linux, this is a no-op — callers must use bwrap +/// bind-over for read-deny. +#[cfg(all(feature = "enforce", unix))] +pub(crate) fn apply_deny_paths_to_capability_set( + caps: &mut CapabilitySet, + deny_paths: &[PathBuf], +) -> anyhow::Result<()> { + if deny_paths.is_empty() { + return Ok(()); + } + + #[cfg(target_os = "macos")] + { + // Every literal path a deny rule was emitted for, so explicit file caps + // colliding with a denied path can be removed for all alias forms too. + let mut rule_paths: Vec = Vec::new(); + for path in deny_paths { + let canonical = dunce::canonicalize(path).unwrap_or_else(|_| path.clone()); + // Dir-ness (subpath vs literal) is decided by existence and applies + // to all alias forms of this path. + let use_subpath = deny_path_is_dir(&canonical); + // The base profile grants `(allow file-read* (subpath "/"))`, which + // matches every *literal* path. macOS reaches /tmp, /var, /etc via + // symlinks into /private, so denying only the canonical form is + // bypassable through the alias — emit a deny for each alias form. + for form in macos_deny_aliases(path, &canonical) { + let Some(escaped) = escape_seatbelt_path(&form) else { + // Fail CLOSED: a deny path we can't express as a Seatbelt + // filter would otherwise be silently unprotected while the + // sandbox still reports active. Erroring leaves apply() not + // applied so the shell's macOS `!is_applied` guard refuses to + // start — matching Linux's any-bind-fails-closed. + anyhow::bail!("cannot escape deny path {form:?} for Seatbelt"); + }; + // `literal` for files, `subpath` for dirs, so deny rules are more + // specific than parent-directory allows. + let filter = if use_subpath { + format!("(subpath \"{escaped}\")") + } else { + format!("(literal \"{escaped}\")") + }; + emit_seatbelt_deny(caps, &filter)?; + rule_paths.push(form); + } + } + let _removed = caps.remove_exact_file_caps_for_paths(&rule_paths); + tracing::info!( + count = deny_paths.len(), + "Applied Seatbelt deny rules for sandbox deny paths" + ); + } + + #[cfg(target_os = "linux")] + { + let _ = caps; + tracing::debug!( + count = deny_paths.len(), + "Linux deny paths require bwrap bind-over (applied at process re-exec)" + ); + } + + Ok(()) +} + +/// Resolve deny path strings from a profile against the workspace. +/// +/// Relative paths are joined with `workspace`. Absolute paths are used as-is. +#[cfg(all(feature = "enforce", unix))] +pub(crate) fn resolve_deny_paths(workspace: &Path, deny: &[PathBuf]) -> Vec { + deny.iter() + .map(|p| { + if p.is_absolute() { + p.clone() + } else { + workspace.join(p) + } + }) + .collect() +} + +/// Resolve, sort, and dedup a profile's deny list into the canonical set of +/// paths to enforce. Shared by the Seatbelt (profiles.rs) and bwrap (lib.rs) sites. +#[cfg(all(feature = "enforce", unix))] +pub(crate) fn effective_deny_paths(workspace: &Path, deny: &[PathBuf]) -> Vec { + let mut paths = resolve_deny_paths(workspace, deny); + paths.sort(); + paths.dedup(); + paths +} + +/// Resolve already-partitioned EXACT (non-glob) deny entries into bwrap bind +/// strings: resolved against `workspace`, sorted, deduped, stringified. The +/// caller passes the exact slice from `partition_deny_entries`. A Linux bwrap +/// concern (macOS denies via Seatbelt, not path strings) — the exact-path +/// parallel to glob's `expand_deny_globs`, so both deny resolutions live in `deny/`. +#[cfg(all(feature = "enforce", target_os = "linux"))] +pub(crate) fn exact_deny_path_strings(workspace: &Path, exact: &[PathBuf]) -> Vec { + effective_deny_paths(workspace, exact) + .into_iter() + .map(|p| p.display().to_string()) + .collect() +} + +/// Whether a deny path should be treated as a directory (Seatbelt `subpath` / +/// bwrap dir-bind) rather than a single file: true for existing directories, +/// false otherwise. Shared by the macOS and Linux deny sites so the two cannot +/// silently diverge. +/// +/// Limitation: a non-existent deny path is treated as a single file (macOS emits +/// `(literal …)`); if it is later created as a directory its children are not +/// covered on macOS. Name concrete existing paths to deny a whole directory tree. +#[cfg(all(feature = "enforce", unix))] +pub(crate) fn deny_path_is_dir(canonical: &Path) -> bool { + canonical.is_dir() +} + +#[cfg(test)] +mod tests { + // All tests here exercise enforce+unix paths; without the gate `super::*` + // is unused on `--no-default-features`. + #[cfg(all(feature = "enforce", unix))] + use super::*; + #[cfg(all(feature = "enforce", unix))] + use pretty_assertions::assert_eq; + + #[test] + #[cfg(all(feature = "enforce", unix))] + fn resolve_deny_paths_relative() { + let ws = PathBuf::from("/tmp/project"); + let deny = vec![PathBuf::from(".env"), PathBuf::from("/etc/shadow")]; + let resolved = resolve_deny_paths(&ws, &deny); + assert_eq!(resolved[0], PathBuf::from("/tmp/project/.env")); + assert_eq!(resolved[1], PathBuf::from("/etc/shadow")); + } + + #[test] + #[cfg(all(feature = "enforce", target_os = "linux"))] + fn exact_deny_path_strings_resolves_sorts_dedups() { + let ws = PathBuf::from("/ws"); + // Already-partitioned exact entries (relative + absolute), with a duplicate. + let exact = vec![ + PathBuf::from("src/server.pem"), + PathBuf::from(".env"), + PathBuf::from("/etc/shadow"), + PathBuf::from(".env"), + ]; + let paths = exact_deny_path_strings(&ws, &exact); + assert!(paths.iter().any(|p| p == "/ws/.env"), "{paths:?}"); + assert!(paths.iter().any(|p| p == "/ws/src/server.pem"), "{paths:?}"); + assert!(paths.iter().any(|p| p == "/etc/shadow"), "{paths:?}"); + // Sorted + deduped (the duplicate `.env` collapses to one). + let mut sorted = paths.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!(paths, sorted, "must be sorted and deduped: {paths:?}"); + assert!( + !paths.iter().any(|p| p.contains('*')), + "no globs: {paths:?}" + ); + } + + #[test] + #[cfg(all(feature = "enforce", target_os = "macos"))] + fn seatbelt_escape_handles_quotes() { + let p = Path::new("/tmp/foo\"bar"); + let escaped = escape_seatbelt_path(p).unwrap(); + assert!(escaped.contains("\\\"")); + } + + #[test] + #[cfg(all(feature = "enforce", target_os = "macos"))] + fn seatbelt_escape_rejects_control_chars() { + assert!(escape_seatbelt_path(Path::new("/tmp/a\u{07}b")).is_none()); + } + + #[test] + #[cfg(all(feature = "enforce", target_os = "macos"))] + fn macos_deny_aliases_cover_private_symlink() { + // A canonical /private/tmp denied path must also be denied via its /tmp alias, + // otherwise the broad read-allow leaves it readable through the alias. + let canonical = Path::new("/private/tmp/proj/.env"); + let aliases = macos_deny_aliases(canonical, canonical); + assert!( + aliases.iter().any(|p| p == Path::new("/tmp/proj/.env")), + "expected /tmp alias in {aliases:?}" + ); + assert_eq!( + toggle_private_prefix(Path::new("/tmp/proj/.env")), + Some(PathBuf::from("/private/tmp/proj/.env")) + ); + // Non-firmlink paths (e.g. home credential dirs) have no alias. + assert_eq!(toggle_private_prefix(Path::new("/Users/x/.ssh")), None); + } +} diff --git a/crates/sandbox/src/lib.rs b/crates/sandbox/src/lib.rs new file mode 100644 index 00000000..a3796f70 --- /dev/null +++ b/crates/sandbox/src/lib.rs @@ -0,0 +1,396 @@ +//! OS-level sandboxing for Devo via [nono](https://crates.io/crates/nono). +//! +//! Applied once at process startup. Covers in-process `tokio::fs` calls and +//! child processes. Network is left open at the process level (the agent needs +//! LLM API access); child network is blocked per-subprocess via seccomp. +//! +//! The `enforce` feature (on by default) pulls in `nono` for kernel-enforced +//! sandboxing (Landlock/Seatbelt). When disabled, the crate still provides +//! lightweight helpers that compile on all targets including musl. +//! +//! ```rust,no_run +//! use devo_sandbox::{ProfileName, SandboxManager}; +//! use std::path::Path; +//! +//! let workspace = Path::new("/home/user/project"); +//! let mut sandbox = SandboxManager::new(ProfileName::Workspace, workspace); +//! sandbox +//! .apply_required(workspace) +//! .expect("required sandbox enforcement failed"); +//! sandbox.install(); +//! ``` +mod bwrap; +pub mod child_net; +mod deny; +mod logging; +mod network_policy; +mod paths; +mod profiles; +mod types; + +#[cfg(target_os = "linux")] +pub use bwrap::bwrap_reexec_for_profile; +pub use bwrap::{ + bwrap_reexec_command, is_inside_bwrap, requires_read_deny, trust_bwrap_marker_for_devbox, +}; +pub use logging::SandboxLogger; +pub use network_policy::{ + ChildNetworkPolicy, NETWORK_POLICY_SNAPSHOT_VERSION, NetworkPolicySnapshot, + NetworkPolicySnapshotError, WebsiteAction, WebsiteOrigin, WebsiteOriginError, WebsitePolicy, +}; +pub use profiles::{ + ProfileName, SandboxConfig, SandboxProfile, load_sandbox_config, sandbox_profile_conflicts, +}; +pub use types::{SandboxEvent, SandboxEventType, SandboxMetrics}; + +#[cfg(all(feature = "enforce", unix))] +use nono::Sandbox; +use std::path::Path; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicBool, Ordering}; + +static SANDBOX: OnceLock = OnceLock::new(); +static CONFIGURED_PROFILE: OnceLock = OnceLock::new(); +static AUTO_ALLOW_BASH: AtomicBool = AtomicBool::new(false); + +struct GlobalSandboxState { + profile: String, + logger: SandboxLogger, + applied: bool, + restrict_network_at_known_linux_launches: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ApplyRequirement { + Graceful, + Required, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ApplyOutcome { + Disabled, + #[cfg_attr(not(all(feature = "enforce", unix)), allow(dead_code))] + Applied, + Unavailable(String), +} + +fn validate_apply_outcome( + profile: &ProfileName, + requirement: ApplyRequirement, + outcome: ApplyOutcome, +) -> anyhow::Result<()> { + match (requirement, outcome) { + (_, ApplyOutcome::Disabled | ApplyOutcome::Applied) + | (ApplyRequirement::Graceful, ApplyOutcome::Unavailable(_)) => Ok(()), + (ApplyRequirement::Required, ApplyOutcome::Unavailable(details)) => anyhow::bail!( + "sandbox profile '{profile}' requires enforcement, but it was not applied: {details}" + ), + } +} + +fn restrict_network_at_known_linux_launches(applied: bool, configured: bool) -> bool { + applied && configured && cfg!(target_os = "linux") +} + +/// Whether known Linux child launch paths should install the seccomp network filter. +pub fn should_restrict_child_network() -> bool { + SANDBOX + .get() + .is_some_and(|state| state.restrict_network_at_known_linux_launches) +} + +/// Whether bash commands should be auto-approved when the sandbox is active. +pub fn should_auto_allow_bash() -> bool { + AUTO_ALLOW_BASH.load(Ordering::Relaxed) && is_active() +} + +pub fn set_auto_allow_bash(enabled: bool) { + AUTO_ALLOW_BASH.store(enabled, Ordering::Relaxed); +} + +/// Record the resolved sandbox profile at process startup (including `"off"`). +pub fn set_configured_profile(name: impl Into) { + let _ = CONFIGURED_PROFILE.set(name.into()); +} + +/// Resolved sandbox profile from startup, or `None` if it was never set. +pub fn configured_profile_name() -> Option<&'static str> { + CONFIGURED_PROFILE.get().map(|name| name.as_str()) +} + +/// Whether the sandbox was successfully applied to this process. +pub fn is_active() -> bool { + SANDBOX.get().is_some_and(|state| state.applied) +} + +/// The active sandbox profile name, or `None` if sandbox is not applied. +pub fn profile_name() -> Option<&'static str> { + SANDBOX + .get() + .filter(|state| state.applied) + .map(|state| state.profile.as_str()) +} + +/// Log a sandbox violation. Immediately flushed to disk. No-op if inactive. +pub fn log_violation(target: &str, operation: &str) { + if let Some(state) = SANDBOX.get() { + state.logger.log(SandboxEvent::fs_violation( + &state.profile, + target, + operation, + )); + let _ = state.logger.flush_to_disk(); + } +} + +/// Flush sandbox events to disk. No-op if not initialized. +pub fn flush() { + if let Some(state) = SANDBOX.get() + && let Err(error) = state.logger.flush_to_disk() + { + tracing::warn!(error = %error, "Failed to flush sandbox events to disk"); + } +} + +/// Violation metrics, or `None` if sandbox is not active. +pub fn metrics() -> Option<&'static SandboxMetrics> { + SANDBOX.get().map(|state| state.logger.metrics()) +} + +/// Manages the OS-level sandbox. Call `apply_required()` when continuing +/// without enforcement would be unsafe, then call `install()`. +pub struct SandboxManager { + profile: ProfileName, + logger: SandboxLogger, + net_restricted: bool, + applied: bool, +} + +impl SandboxManager { + /// Create a sandbox manager. Does not apply until `apply()` is called. + pub fn new(profile: ProfileName, _workspace: &Path) -> Self { + let net_restricted = profile.restricts_network(); + Self { + profile, + logger: SandboxLogger::new(), + net_restricted, + applied: false, + } + } + + /// Attempt to apply the sandbox to the current process. **Irreversible.** + /// + /// This compatibility API degrades gracefully: `Ok(())` does not mean + /// enforcement is active. Call [`Self::is_applied`] to inspect the outcome, + /// or use [`Self::apply_required`] when enforcement is mandatory. + pub fn apply(&mut self, workspace: &Path) -> anyhow::Result<()> { + self.apply_with_requirement(workspace, ApplyRequirement::Graceful) + } + + /// Apply the sandbox and fail unless the selected non-`off` profile is + /// kernel-enforced. **Irreversible.** + /// + /// This is the fail-closed entry point for child runners and other callers + /// that must not continue when enforcement is unsupported or application + /// fails. Selecting `off` remains an explicit successful opt-out. + pub fn apply_required(&mut self, workspace: &Path) -> anyhow::Result<()> { + self.apply_with_requirement(workspace, ApplyRequirement::Required) + } + + #[cfg(all(feature = "enforce", unix))] + fn apply_with_requirement( + &mut self, + workspace: &Path, + requirement: ApplyRequirement, + ) -> anyhow::Result<()> { + if self.profile == ProfileName::Off { + tracing::info!("Sandbox disabled (profile: off)"); + return validate_apply_outcome(&self.profile, requirement, ApplyOutcome::Disabled); + } + let config = profiles::load_sandbox_config(workspace)?; + let mut resolved = self.profile.resolve_profile(workspace, &config)?; + self.net_restricted = resolved.restrict_network; + let support = Sandbox::support_info(); + if !support.is_supported { + tracing::warn!( + details = %support.details, + "Sandbox not supported on this platform, continuing without sandbox" + ); + self.logger.log(SandboxEvent::apply_failed( + &self.profile.to_string(), + workspace, + &support.details, + )); + return validate_apply_outcome( + &self.profile, + requirement, + ApplyOutcome::Unavailable(support.details), + ); + } + let caps = ProfileName::capability_set_from_profile(workspace, &resolved)?; + resolved.deny = deny::effective_deny_paths(workspace, &resolved.deny); + match Sandbox::apply(&caps) { + Ok(_) => { + self.applied = true; + self.logger.log(SandboxEvent::profile_applied( + &self.profile.to_string(), + workspace, + &resolved, + )); + tracing::info!( + profile = %self.profile, + workspace = %workspace.display(), + restrict_network_configured = self.net_restricted, + "Sandbox applied (kernel-enforced, irreversible)" + ); + validate_apply_outcome(&self.profile, requirement, ApplyOutcome::Applied) + } + Err(error) => { + tracing::warn!( + profile = %self.profile, + error = %error, + "Sandbox could not be applied, continuing without sandbox" + ); + self.logger.log(SandboxEvent::apply_failed( + &self.profile.to_string(), + workspace, + &error, + )); + validate_apply_outcome( + &self.profile, + requirement, + ApplyOutcome::Unavailable(error.to_string()), + ) + } + } + } + + #[cfg(not(all(feature = "enforce", unix)))] + fn apply_with_requirement( + &mut self, + _workspace: &Path, + requirement: ApplyRequirement, + ) -> anyhow::Result<()> { + if self.profile == ProfileName::Off { + tracing::info!("Sandbox disabled (profile: off)"); + return validate_apply_outcome(&self.profile, requirement, ApplyOutcome::Disabled); + } + tracing::info!( + profile = %self.profile, + "Sandbox enforcement unavailable (built without 'enforce' feature)" + ); + validate_apply_outcome( + &self.profile, + requirement, + ApplyOutcome::Unavailable("built without the 'enforce' feature".to_string()), + ) + } + + /// Store globally for session-lifetime violation logging. + pub fn install(self) { + let _ = self.logger.flush_to_disk(); + let _ = SANDBOX.set(GlobalSandboxState { + profile: self.profile.to_string(), + logger: self.logger, + applied: self.applied, + restrict_network_at_known_linux_launches: restrict_network_at_known_linux_launches( + self.applied, + self.net_restricted, + ), + }); + } + + /// Check whether the current platform supports sandboxing. + #[cfg(all(feature = "enforce", unix))] + pub fn support_info() -> nono::SupportInfo { + Sandbox::support_info() + } + + /// Whether the sandbox was successfully applied. + pub fn is_applied(&self) -> bool { + self.applied + } + + /// Whether known Linux child launch paths should install the seccomp filter. + pub fn restrict_child_network(&self) -> bool { + restrict_network_at_known_linux_launches(self.applied, self.net_restricted) + } + + /// The active profile name. + pub fn profile(&self) -> &ProfileName { + &self.profile + } + + /// Access the sandbox event logger (before `install()`). + pub fn logger(&self) -> &SandboxLogger { + &self.logger + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn configured_profile_is_recorded() { + set_configured_profile("read-only"); + assert_eq!(configured_profile_name(), Some("read-only")); + } + + #[test] + fn known_launch_guard_is_linux_only() { + assert_eq!( + restrict_network_at_known_linux_launches( + /*applied*/ true, /*configured*/ true + ), + cfg!(target_os = "linux") + ); + assert!(!restrict_network_at_known_linux_launches( + /*applied*/ false, /*configured*/ true + )); + assert!(!restrict_network_at_known_linux_launches( + /*applied*/ true, /*configured*/ false + )); + } + + #[test] + fn required_apply_rejects_unavailable_enforcement() { + let error = validate_apply_outcome( + &ProfileName::Strict, + ApplyRequirement::Required, + ApplyOutcome::Unavailable("unsupported in test".to_string()), + ) + .expect_err("required enforcement must fail closed"); + + assert_eq!( + error.to_string(), + "sandbox profile 'strict' requires enforcement, but it was not applied: unsupported in test" + ); + } + + #[test] + fn graceful_apply_accepts_unavailable_enforcement() { + assert!( + validate_apply_outcome( + &ProfileName::Strict, + ApplyRequirement::Graceful, + ApplyOutcome::Unavailable("unsupported in test".to_string()), + ) + .is_ok() + ); + } + + #[test] + fn required_apply_accepts_applied_or_explicitly_disabled_outcomes() { + for (profile, outcome) in [ + (ProfileName::Strict, ApplyOutcome::Applied), + (ProfileName::Off, ApplyOutcome::Disabled), + ] { + assert!( + validate_apply_outcome(&profile, ApplyRequirement::Required, outcome).is_ok(), + "unexpected rejection for {profile}" + ); + } + } +} diff --git a/crates/sandbox/src/logging.rs b/crates/sandbox/src/logging.rs new file mode 100644 index 00000000..c9d59fef --- /dev/null +++ b/crates/sandbox/src/logging.rs @@ -0,0 +1,124 @@ +//! Sandbox event logger. +//! +//! Records sandbox events (profile applied, violations, bypasses) for +//! telemetry and debugging. Events are kept in memory and can be flushed +//! to a JSONL file at `~/.devo/sandbox-events.jsonl`. + +use std::path::PathBuf; +use std::sync::Mutex; + +use crate::types::{SandboxEvent, SandboxEventType, SandboxMetrics}; + +/// Logger that collects sandbox events and maintains violation counters. +pub struct SandboxLogger { + events: Mutex>, + metrics: SandboxMetrics, +} + +impl SandboxLogger { + pub fn new() -> Self { + Self { + events: Mutex::new(Vec::new()), + metrics: SandboxMetrics::default(), + } + } + + /// Record an event, updating metrics counters as appropriate. + pub fn log(&self, event: SandboxEvent) { + match &event.event_type { + SandboxEventType::FsViolation => self.metrics.inc_fs_violation(), + SandboxEventType::NetViolation => self.metrics.inc_net_violation(), + SandboxEventType::BypassGranted => self.metrics.inc_bypass_granted(), + SandboxEventType::BypassDenied => self.metrics.inc_bypass_denied(), + SandboxEventType::ProfileApplied | SandboxEventType::ApplyFailed => {} + } + + tracing::debug!( + event_type = ?event.event_type, + profile = %event.profile, + target = ?event.target, + operation = ?event.operation, + "sandbox event" + ); + + if let Ok(mut events) = self.events.lock() { + events.push(event); + } + } + + /// Get a reference to the metrics counters. + pub fn metrics(&self) -> &SandboxMetrics { + &self.metrics + } + + /// Take all accumulated events, draining the internal buffer. + pub fn take_events(&self) -> Vec { + self.events + .lock() + .map(|mut events| std::mem::take(&mut *events)) + .unwrap_or_default() + } + + /// Flush accumulated events to the JSONL log file. + /// Each event is written as a single JSON line. + pub fn flush_to_disk(&self) -> anyhow::Result<()> { + let events = self.take_events(); + if events.is_empty() { + return Ok(()); + } + + let log_path = Self::log_file_path()?; + if let Some(parent) = log_path.parent() { + std::fs::create_dir_all(parent)?; + } + + use std::io::Write; + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path)?; + + for event in &events { + if let Ok(json) = serde_json::to_string(event) { + writeln!(file, "{json}")?; + } + } + + tracing::debug!( + path = %log_path.display(), + count = events.len(), + "flushed sandbox events to disk" + ); + + Ok(()) + } + + fn log_file_path() -> anyhow::Result { + Self::log_file_path_from_home(crate::paths::devo_home()) + } + + fn log_file_path_from_home(home: anyhow::Result) -> anyhow::Result { + Ok(home?.join("sandbox-events.jsonl")) + } +} + +impl Default for SandboxLogger { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn log_path_propagates_home_resolution_errors() { + let error = + SandboxLogger::log_file_path_from_home(Err(anyhow::anyhow!("test home unavailable"))) + .expect_err("log path requires a resolved home"); + + assert_eq!(error.to_string(), "test home unavailable"); + } +} diff --git a/crates/sandbox/src/network_policy.rs b/crates/sandbox/src/network_policy.rs new file mode 100644 index 00000000..29a374c2 --- /dev/null +++ b/crates/sandbox/src/network_policy.rs @@ -0,0 +1,503 @@ +//! Pure policy modeling for future child website egress. +//! +//! These types are not selected by sandbox profiles or enforced by the current +//! runtime. Constructing a policy does not grant or restrict network access. + +use std::collections::BTreeSet; +use std::fmt; +use std::net::IpAddr; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use url::{Host, Url}; + +/// Version of the compact JSON produced by [`NetworkPolicySnapshot`]. +pub const NETWORK_POLICY_SNAPSHOT_VERSION: u32 = 1; + +/// Requested child-network behavior for future enforcement backends. +/// +/// This is not currently selected or enforced by the sandbox runtime. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "mode", content = "policy", rename_all = "snake_case")] +#[non_exhaustive] +pub enum ChildNetworkPolicy { + Unrestricted, + Blocked, + Websites(WebsitePolicy), +} + +impl ChildNetworkPolicy { + pub fn from_restrict_network(restrict_network: bool) -> Self { + if restrict_network { + Self::Blocked + } else { + Self::Unrestricted + } + } +} + +/// Result of exact-origin website policy evaluation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum WebsiteAction { + Allow, + Deny, +} + +/// Exact HTTP(S) origin with an IDNA ASCII hostname and effective nonzero port. +/// +/// Equality never includes subdomains, redirects, paths, or another scheme or +/// port. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct WebsiteOrigin { + scheme: String, + hostname: String, + port: u16, +} + +impl WebsiteOrigin { + /// Parses only `http://authority`, `https://authority`, or either with `/`. + pub fn parse(value: &str) -> Result { + validate_raw_origin(value)?; + let url = Url::parse(value).map_err(|error| WebsiteOriginError::InvalidOrigin { + value: value.to_owned(), + reason: error.to_string(), + })?; + let scheme = url.scheme(); + let hostname = match url.host() { + Some(Host::Domain(hostname)) => hostname.strip_suffix('.').unwrap_or(hostname), + Some(Host::Ipv4(_) | Host::Ipv6(_)) => return Err(WebsiteOriginError::IpLiteral), + None => return Err(WebsiteOriginError::InvalidSyntax), + }; + let hostname = hostname.to_ascii_lowercase(); + if !valid_dns_hostname(&hostname) || hostname.parse::().is_ok() { + return Err(WebsiteOriginError::InvalidHost(hostname)); + } + let port = url + .port_or_known_default() + .ok_or(WebsiteOriginError::InvalidSyntax)?; + if port == 0 { + return Err(WebsiteOriginError::PortZero); + } + + Ok(Self { + scheme: scheme.to_owned(), + hostname, + port, + }) + } + + pub fn scheme(&self) -> &str { + &self.scheme + } + + pub fn hostname(&self) -> &str { + &self.hostname + } + + pub fn port(&self) -> u16 { + self.port + } +} + +fn validate_raw_origin(value: &str) -> Result<(), WebsiteOriginError> { + if value.bytes().any(|byte| byte <= b' ' || byte == 0x7f) { + return Err(WebsiteOriginError::InvalidSyntax); + } + if value.contains('\\') { + return Err(WebsiteOriginError::InvalidSyntax); + } + let authority = value + .strip_prefix("http://") + .or_else(|| value.strip_prefix("https://")) + .ok_or(WebsiteOriginError::InvalidSyntax)?; + let authority = authority.strip_suffix('/').unwrap_or(authority); + if authority.is_empty() + || authority.contains('/') + || authority.contains('?') + || authority.contains('#') + { + return Err(WebsiteOriginError::InvalidSyntax); + } + if authority.contains('@') { + return Err(WebsiteOriginError::Userinfo); + } + if authority.contains('*') { + return Err(WebsiteOriginError::Wildcard); + } + Ok(()) +} + +fn valid_dns_hostname(hostname: &str) -> bool { + !hostname.is_empty() + && hostname.len() <= 253 + && hostname.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + && label + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + && label + .as_bytes() + .last() + .is_some_and(u8::is_ascii_alphanumeric) + }) +} + +impl fmt::Display for WebsiteOrigin { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}://{}:{}", self.scheme, self.hostname, self.port) + } +} + +impl FromStr for WebsiteOrigin { + type Err = WebsiteOriginError; + + fn from_str(value: &str) -> Result { + Self::parse(value) + } +} + +impl Serialize for WebsiteOrigin { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for WebsiteOrigin { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(serde::de::Error::custom) + } +} + +/// Immutable exact-origin rules with deny precedence over allow and default. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WebsitePolicy { + default: WebsiteAction, + allow: BTreeSet, + deny: BTreeSet, +} + +impl WebsitePolicy { + pub fn new( + default: WebsiteAction, + allow: impl IntoIterator, + deny: impl IntoIterator, + ) -> Self { + Self { + default, + allow: allow.into_iter().collect(), + deny: deny.into_iter().collect(), + } + } + + pub fn default_action(&self) -> WebsiteAction { + self.default + } + + pub fn allow(&self) -> &BTreeSet { + &self.allow + } + + pub fn deny(&self) -> &BTreeSet { + &self.deny + } + + /// Evaluates deny exact match, then allow exact match, then the default. + pub fn evaluate(&self, origin: &WebsiteOrigin) -> WebsiteAction { + if self.deny.contains(origin) { + WebsiteAction::Deny + } else if self.allow.contains(origin) { + WebsiteAction::Allow + } else { + self.default + } + } +} + +/// Versioned deterministic JSON and SHA-256 identity for later persistence. +/// +/// The snapshot is not currently written to sessions or used for enforcement. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct NetworkPolicySnapshot { + version: u32, + policy: ChildNetworkPolicy, +} + +impl NetworkPolicySnapshot { + pub fn new(policy: ChildNetworkPolicy) -> Self { + Self { + version: NETWORK_POLICY_SNAPSHOT_VERSION, + policy, + } + } + + pub fn version(&self) -> u32 { + self.version + } + + pub fn policy(&self) -> &ChildNetworkPolicy { + &self.policy + } + + pub fn into_policy(self) -> ChildNetworkPolicy { + self.policy + } + + /// Serializes the stable compact JSON representation for this version. + pub fn canonical_json(&self) -> Result { + Ok(serde_json::to_string(self)?) + } + + /// Returns SHA-256 hex over [`Self::canonical_json`]. + pub fn sha256(&self) -> Result { + Ok(format!( + "{:x}", + Sha256::digest(self.canonical_json()?.as_bytes()) + )) + } + + pub fn validate_sha256(&self, expected: &str) -> Result { + Ok(self.sha256()?.eq_ignore_ascii_case(expected)) + } + + /// Decodes the version envelope before interpreting its policy payload. + pub fn from_canonical_json(value: &str) -> Result { + #[derive(Deserialize)] + struct RawSnapshot { + version: u32, + policy: serde_json::Value, + } + + let raw: RawSnapshot = serde_json::from_str(value)?; + if raw.version != NETWORK_POLICY_SNAPSHOT_VERSION { + return Err(NetworkPolicySnapshotError::UnsupportedVersion(raw.version)); + } + Ok(Self { + version: raw.version, + policy: serde_json::from_value(raw.policy)?, + }) + } +} + +/// Validation failures for strict raw exact-origin syntax. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +#[non_exhaustive] +pub enum WebsiteOriginError { + #[error("website origin must use exact http(s)://authority syntax with optional '/'")] + InvalidSyntax, + #[error("invalid website origin '{value}': {reason}")] + InvalidOrigin { value: String, reason: String }, + #[error("website origin must not contain userinfo")] + Userinfo, + #[error("website origin must not contain wildcards")] + Wildcard, + #[error("website origin must not use an IP literal")] + IpLiteral, + #[error("invalid website origin hostname '{0}'")] + InvalidHost(String), + #[error("website origin port must be nonzero")] + PortZero, +} + +/// Snapshot encoding, decoding, and version failures. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum NetworkPolicySnapshotError { + #[error("invalid network policy snapshot: {0}")] + InvalidJson(#[from] serde_json::Error), + #[error("unsupported network policy snapshot version {0}")] + UnsupportedVersion(u32), +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + fn origin(value: &str) -> WebsiteOrigin { + WebsiteOrigin::parse(value).unwrap() + } + + #[test] + fn normalizes_default_ports_case_trailing_dot_and_idna() { + let http = origin("http://Example.COM"); + assert_eq!(http, origin("http://example.com:80/")); + assert_eq!(http.scheme(), "http"); + assert_eq!(http.port(), 80); + + let https = origin("https://example.com."); + assert_eq!(https, origin("https://EXAMPLE.com:443")); + assert_eq!(https.port(), 443); + assert_eq!( + origin("https://bücher.example").hostname(), + "xn--bcher-kva.example" + ); + assert_eq!(origin("https://example.com:8443").port(), 8443); + } + + #[test] + fn rejects_non_origin_inputs() { + let cases = [ + ("ftp://example.com", "exact http(s)"), + ("https://127.0.0.1", "IP literal"), + ("https://[::1]", "IP literal"), + ("https://user@example.com", "userinfo"), + ("https://@example.com", "userinfo"), + ("https://:@example.com", "userinfo"), + ("https://example.com/path", "exact http(s)"), + ("https://example.com/?query=1", "exact http(s)"), + ("https://example.com/#fragment", "exact http(s)"), + ("https://*.example.com", "wildcards"), + ("https://example.*", "wildcards"), + ("https://example.com:0", "nonzero"), + ( + "https://bad_host.example", + "invalid website origin hostname", + ), + ("https://", "exact http(s)"), + ]; + + for (value, expected) in cases { + let error = WebsiteOrigin::parse(value).unwrap_err().to_string(); + assert!(error.contains(expected), "{value}: {error}"); + } + } + + #[test] + fn rejects_url_parser_repairs_and_ignored_characters() { + for value in [ + "https:example.com", + "https:/example.com", + "https:///example.com", + "https:\\example.com", + "https://example.com/..", + " https://example.com", + "https://example.com ", + "https://exam\tple.com", + "https://example.com\n", + ] { + assert_eq!( + WebsiteOrigin::parse(value), + Err(WebsiteOriginError::InvalidSyntax), + "{value:?}" + ); + } + } + + #[test] + fn evaluates_exact_origin_with_deny_precedence() { + let exact = origin("https://example.com"); + let allowed = origin("https://allowed.example"); + let policy = WebsitePolicy::new( + WebsiteAction::Deny, + [exact.clone(), allowed.clone()], + [exact.clone()], + ); + + assert_eq!(policy.evaluate(&exact), WebsiteAction::Deny); + assert_eq!(policy.evaluate(&allowed), WebsiteAction::Allow); + for different in [ + "http://example.com", + "https://sub.example.com", + "https://example.com:8443", + ] { + assert_eq!(policy.evaluate(&origin(different)), WebsiteAction::Deny); + } + } + + #[test] + fn default_action_applies_after_exact_rules() { + let allowed = origin("https://allowed.example"); + let denied = origin("https://denied.example"); + let policy = WebsitePolicy::new(WebsiteAction::Deny, [allowed.clone()], [denied.clone()]); + + assert_eq!(policy.evaluate(&allowed), WebsiteAction::Allow); + assert_eq!(policy.evaluate(&denied), WebsiteAction::Deny); + assert_eq!( + policy.evaluate(&origin("https://other.example")), + WebsiteAction::Deny + ); + } + + #[test] + fn snapshot_deduplicates_sorts_and_hashes_independent_of_input_order() { + let a = origin("https://a.example"); + let b = origin("https://b.example"); + let first = WebsitePolicy::new( + WebsiteAction::Deny, + [b.clone(), a.clone(), b.clone()], + [b.clone(), a.clone()], + ); + let second = WebsitePolicy::new( + WebsiteAction::Deny, + [a.clone(), b.clone()], + [a.clone(), b.clone(), a.clone()], + ); + let first = NetworkPolicySnapshot::new(ChildNetworkPolicy::Websites(first)); + let second = NetworkPolicySnapshot::new(ChildNetworkPolicy::Websites(second)); + + assert_eq!(first, second); + let ChildNetworkPolicy::Websites(policy) = first.policy() else { + panic!("expected website policy") + }; + assert_eq!(policy.allow().iter().collect::>(), vec![&a, &b]); + assert_eq!(first.sha256().unwrap(), second.sha256().unwrap()); + assert!(first.validate_sha256(&first.sha256().unwrap()).unwrap()); + assert!(!first.validate_sha256(&"0".repeat(64)).unwrap()); + } + + #[test] + fn snapshot_roundtrip_preserves_policy_and_hash() { + let policy = ChildNetworkPolicy::Websites(WebsitePolicy::new( + WebsiteAction::Deny, + [origin("https://allowed.example")], + [origin("http://denied.example:8080")], + )); + let snapshot = NetworkPolicySnapshot::new(policy.clone()); + let json = snapshot.canonical_json().unwrap(); + let expected = r#"{"version":1,"policy":{"mode":"websites","policy":{"default":"deny","allow":["https://allowed.example:443"],"deny":["http://denied.example:8080"]}}}"#; + assert_eq!(json, expected); + assert_eq!( + snapshot.sha256().unwrap(), + "1b076f4854a41891304774143110ef54eb9936160d3d0ea3db91ca08f1e06f84" + ); + + let decoded = NetworkPolicySnapshot::from_canonical_json(expected).unwrap(); + assert_eq!(decoded.version(), NETWORK_POLICY_SNAPSHOT_VERSION); + assert_eq!(decoded.policy(), &policy); + assert_eq!(decoded.clone().into_policy(), policy); + assert_eq!(decoded.sha256().unwrap(), snapshot.sha256().unwrap()); + let wrong_version = r#"{"version":2,"policy":{"mode":"future_mode"}}"#; + assert!(matches!( + NetworkPolicySnapshot::from_canonical_json(wrong_version), + Err(NetworkPolicySnapshotError::UnsupportedVersion(2)) + )); + } + + #[test] + fn legacy_restriction_maps_without_selecting_websites() { + assert_eq!( + ChildNetworkPolicy::from_restrict_network(false), + ChildNetworkPolicy::Unrestricted + ); + assert_eq!( + ChildNetworkPolicy::from_restrict_network(true), + ChildNetworkPolicy::Blocked + ); + } +} diff --git a/crates/sandbox/src/paths.rs b/crates/sandbox/src/paths.rs new file mode 100644 index 00000000..16f86685 --- /dev/null +++ b/crates/sandbox/src/paths.rs @@ -0,0 +1,148 @@ +//! Filesystem path tables for sandbox profiles. +//! +//! Collects device files, temp directories, sensitive deny-paths, and +//! ecosystem (package-manager / toolchain) writable paths into helpers +//! consumed by [`super::profiles`]. + +use anyhow::Context; +use std::path::{Path, PathBuf}; + +// ── Devo state directory ──────────────────────────────────────────────────── + +/// Devo state directory — always writable (`$DEVO_HOME` or `~/.devo`). +pub(crate) fn devo_home() -> anyhow::Result { + devo_home_with(devo_util_paths::find_devo_home) +} + +fn devo_home_with(resolver: impl FnOnce() -> std::io::Result) -> anyhow::Result { + resolver().context("failed to resolve Devo home directory") +} + +// ── Device files & directories ────────────────────────────────────────────── + +/// Device files that need write access for normal tool operation. +/// +/// Without write access to these, common programs (git, curl, ssh, compilers) +/// break because they can't open `/dev/null` as an output sink, allocate PTYs, +/// or seed RNGs. +/// +/// These are individual files (use `allow_file`, not `allow_path`). +/// `/dev/pts` is a directory (PTY slaves on Linux) so it uses `allow_path`. +#[cfg(all(feature = "enforce", unix))] +pub(crate) const DEVICE_FILES: &[&str] = &[ + "/dev/null", // output sink — used by virtually every CLI tool + "/dev/zero", // zero source — used by memory allocators + "/dev/random", // entropy — used by crypto/TLS + "/dev/urandom", // entropy — used by crypto/TLS + "/dev/tty", // controlling terminal — used by git, ssh, gpg + "/dev/ptmx", // PTY allocation — used by terminal spawning + "/dev/fd", // file descriptor access (symlink to /proc/self/fd on Linux) +]; + +/// Device directories that need write access. +#[cfg(all(feature = "enforce", unix))] +pub(crate) const DEVICE_DIRS: &[&str] = &[ + "/dev/pts", // PTY slaves (Linux) +]; + +// ── Temporary directories ─────────────────────────────────────────────────── + +/// Temporary directories that need write access. +/// +/// On Linux, `/tmp` is the standard temp directory. +/// On macOS, programs use both `/tmp` (symlink to `/private/tmp`) and +/// `/private/var/folders/` (the real `TMPDIR` / `NSTemporaryDirectory()`). +/// git, compilers, and other tools write temp files to `$TMPDIR` which +/// resolves to `/private/var/folders/xx/.../T/` on macOS. +pub(crate) fn temp_writable_paths() -> Vec { + let mut paths = vec![PathBuf::from("/tmp"), PathBuf::from("/var/tmp")]; + + // macOS: /tmp → /private/tmp, but the real TMPDIR is under /private/var/folders. + // Also include /private/tmp since Seatbelt may resolve the symlink. + if cfg!(target_os = "macos") { + for p in ["/private/tmp", "/private/var/tmp", "/private/var/folders"] { + let pb = PathBuf::from(p); + if pb.exists() && pb.is_dir() { + paths.push(pb); + } + } + } + + // Respect $TMPDIR if it points somewhere else (e.g. custom Linux setups). + if let Ok(tmpdir) = std::env::var("TMPDIR") { + let pb = PathBuf::from(&tmpdir); + if pb.exists() && pb.is_dir() && !paths.contains(&pb) { + paths.push(pb); + } + } + + paths +} + +// ── Essential writable paths ──────────────────────────────────────────────── + +/// Writable directory paths for profiles that allow workspace writes (workspace, devbox, strict). +/// Device files are handled separately via `allow_file` in `to_capability_set_with_config`. +pub(crate) fn essential_writable_paths(workspace: &Path) -> anyhow::Result> { + essential_writable_paths_with_home(workspace, devo_home()) +} + +fn essential_writable_paths_with_home( + workspace: &Path, + home: anyhow::Result, +) -> anyhow::Result> { + let mut paths = vec![workspace.to_path_buf(), home?]; + paths.extend(temp_writable_paths()); + Ok(paths) +} + +/// Writable directory paths for the read-only profile (minimal: just ~/.devo + temp). +/// Device files are handled separately via `allow_file` in `to_capability_set_with_config`. +pub(crate) fn essential_writable_paths_minimal() -> anyhow::Result> { + essential_writable_paths_minimal_with_home(devo_home()) +} + +fn essential_writable_paths_minimal_with_home( + home: anyhow::Result, +) -> anyhow::Result> { + let mut paths = vec![home?]; + paths.extend(temp_writable_paths()); + Ok(paths) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn unresolved_home() -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "test home unavailable", + )) + } + + #[test] + fn devo_home_resolution_is_fallible_without_environment_mutation() { + let error = devo_home_with(unresolved_home).expect_err("home resolution must fail"); + + assert!(error.to_string().contains("failed to resolve Devo home")); + } + + #[test] + fn essential_paths_propagate_home_resolution_errors() { + let workspace = Path::new("/workspace"); + let error = essential_writable_paths_with_home( + workspace, + Err(anyhow::anyhow!("test home unavailable")), + ) + .expect_err("essential paths require a resolved home"); + let minimal_error = essential_writable_paths_minimal_with_home(Err(anyhow::anyhow!( + "test minimal home unavailable" + ))) + .expect_err("minimal essential paths require a resolved home"); + + assert_eq!(error.to_string(), "test home unavailable"); + assert_eq!(minimal_error.to_string(), "test minimal home unavailable"); + } +} diff --git a/crates/sandbox/src/profiles.rs b/crates/sandbox/src/profiles.rs new file mode 100644 index 00000000..2e8fbb65 --- /dev/null +++ b/crates/sandbox/src/profiles.rs @@ -0,0 +1,981 @@ +//! Sandbox profiles. Built-in: `workspace`, `devbox`, `read-only`, `strict`, +//! `off`. Custom profiles via `~/.devo/sandbox.toml` or `.devo/sandbox.toml`. +//! A custom profile's `deny` list is kernel-enforced (read + write/rename) on +//! both platforms. + +use anyhow::Context; +#[cfg(all(feature = "enforce", unix))] +use nono::{AccessMode, CapabilitySet}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +#[cfg(all(feature = "enforce", unix))] +use crate::deny::{ + apply_deny_globs_to_capability_set, apply_deny_paths_to_capability_set, effective_deny_paths, + partition_deny_entries, +}; +use crate::paths::devo_home; +#[cfg(all(feature = "enforce", unix))] +use crate::paths::{DEVICE_DIRS, DEVICE_FILES}; +use crate::paths::{essential_writable_paths, essential_writable_paths_minimal}; + +/// A resolved sandbox profile ready to be converted to a `CapabilitySet`. +#[derive(Debug, Clone)] +pub struct SandboxProfile { + /// Display name + pub name: String, + /// Paths the agent can read (but not write) + pub read_only: Vec, + /// Paths the agent can read and write + pub read_write: Vec, + /// Paths denied entirely (overrides read_only/read_write) + pub deny: Vec, + /// Whether to grant read access to the entire filesystem by default + pub default_read: bool, + /// Whether child processes should have network blocked + pub restrict_network: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ProfileConfig { + #[serde(default)] + pub extends: Option, + #[serde(default)] + pub restrict_network: Option, + #[serde(default)] + pub read_only: Vec, + #[serde(default)] + pub read_write: Vec, + #[serde(default)] + pub deny: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] +pub struct SandboxConfig { + #[serde(default)] + pub profiles: HashMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum ProfileName { + #[default] + Workspace, + Devbox, + ReadOnly, + Strict, + Off, + Custom(String), +} + +impl ProfileName { + pub(crate) fn restricts_network(&self) -> bool { + matches!(self, Self::ReadOnly | Self::Strict) + } +} + +impl std::fmt::Display for ProfileName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Workspace => write!(f, "workspace"), + Self::Devbox => write!(f, "devbox"), + Self::ReadOnly => write!(f, "read-only"), + Self::Strict => write!(f, "strict"), + Self::Off => write!(f, "off"), + Self::Custom(name) => write!(f, "{name}"), + } + } +} + +impl std::str::FromStr for ProfileName { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "workspace" => Ok(Self::Workspace), + "devbox" => Ok(Self::Devbox), + "read-only" | "readonly" => Ok(Self::ReadOnly), + "strict" => Ok(Self::Strict), + "off" | "none" => Ok(Self::Off), + // Anything else is treated as a custom profile name. + // Validation happens when we try to load it from config. + other => Ok(Self::Custom(other.to_string())), + } + } +} + +/// Load sandbox config from `~/.devo/sandbox.toml` and `.devo/sandbox.toml`. +/// +/// Project config may **add** new profile names only. It cannot redefine a +/// name already present in the global config — last-write-wins would let a +/// malicious workspace hollow out a user/enterprise custom profile (e.g. +/// empty `deny` / broad `read_write`) while keeping the trusted name. +/// +/// Missing files are treated as absent; all other I/O and TOML errors are +/// returned without merging project profiles. +pub fn load_sandbox_config(workspace: &Path) -> anyhow::Result { + let global_path = devo_home()?.join("sandbox.toml"); + let project_path = workspace.join(".devo").join("sandbox.toml"); + load_sandbox_config_from_paths(&global_path, &project_path) +} + +pub fn sandbox_profile_conflicts(workspace: &Path) -> anyhow::Result> { + let global_path = devo_home()?.join("sandbox.toml"); + let project_path = workspace.join(".devo").join("sandbox.toml"); + let global = load_config_file(&global_path) + .with_context(|| { + format!( + "failed to load global sandbox config at {}", + global_path.display() + ) + })? + .unwrap_or_default(); + let project = load_config_file(&project_path) + .with_context(|| { + format!( + "failed to load project sandbox config at {}", + project_path.display() + ) + })? + .unwrap_or_default(); + + Ok(mismatched_profile_names(&global, &project)) +} + +fn load_sandbox_config_from_paths( + global_path: &Path, + project_path: &Path, +) -> anyhow::Result { + // Read global first so a malformed or unreadable trusted config cannot be + // silently replaced by a project profile with the same name. + let mut config = load_config_file(global_path) + .with_context(|| { + format!( + "failed to load global sandbox config at {}", + global_path.display() + ) + })? + .unwrap_or_default(); + + // Project config is additive only and is read only after global succeeds. + if let Some(project) = load_config_file(project_path).with_context(|| { + format!( + "failed to load project sandbox config at {}", + project_path.display() + ) + })? { + merge_project_profiles(&mut config, project); + } + + Ok(config) +} + +fn mismatched_profile_names(global: &SandboxConfig, project: &SandboxConfig) -> Vec { + let mut names: Vec = project + .profiles + .iter() + .filter(|(name, _)| matches!(name.parse(), Ok(ProfileName::Custom(_)))) + .filter_map(|(name, project_profile)| { + global + .profiles + .get(name) + .filter(|global_profile| *global_profile != project_profile) + .map(|_| name.to_owned()) + }) + .collect(); + names.sort_unstable(); + names +} + +/// Merge project profiles into `config`. Names already defined globally are +/// ignored so a workspace cannot replace a global custom profile's policy. +fn merge_project_profiles(config: &mut SandboxConfig, project: SandboxConfig) { + for (name, profile) in project.profiles { + config.profiles.entry(name).or_insert(profile); + } +} + +fn load_config_file(path: &Path) -> anyhow::Result> { + let content = match std::fs::read_to_string(path) { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error).context("could not read sandbox config"), + }; + toml::from_str(&content) + .map(Some) + .context("could not parse sandbox config") +} + +impl ProfileName { + /// Convert this profile into a nono `CapabilitySet` for the given workspace. + #[cfg(all(feature = "enforce", unix))] + pub fn to_capability_set(&self, workspace: &Path) -> anyhow::Result { + let config = load_sandbox_config(workspace)?; + self.to_capability_set_with_config(workspace, &config) + } + + /// Convert using an already-loaded config (avoids re-reading disk). + /// + /// A custom profile's own `deny` list is kernel-enforced (read + write/rename) + /// on top of the base profile. + #[cfg(all(feature = "enforce", unix))] + pub fn to_capability_set_with_config( + &self, + workspace: &Path, + config: &SandboxConfig, + ) -> anyhow::Result { + if *self == Self::Off { + return Ok(CapabilitySet::new()); + } + + let profile = self.resolve_profile(workspace, config)?; + Self::capability_set_from_profile(workspace, &profile) + } + + #[cfg(all(feature = "enforce", unix))] + pub(crate) fn capability_set_from_profile( + workspace: &Path, + profile: &SandboxProfile, + ) -> anyhow::Result { + let mut caps = CapabilitySet::new(); + + // Default read access + if profile.default_read { + caps = caps.allow_path("/", AccessMode::Read)?; + } + + // Explicit read-only paths — skip non-existent (nothing to read) + for path in &profile.read_only { + if !path.exists() { + continue; + } + let Some(path_str) = path.to_str() else { + tracing::warn!(path = ?path, "Skipping non-UTF8 read_only path"); + continue; + }; + caps = caps.allow_path(path_str, AccessMode::Read)?; + } + + // Read-write paths. nono/Landlock need the directory to exist at + // apply time (it opens an O_PATH fd), but new files within it can + // be created freely after the sandbox is applied. Pre-create + // directories like ~/.devo/ that may not exist on first run. + for path in &profile.read_write { + if !path.exists() && std::fs::create_dir_all(path).is_err() { + tracing::warn!(path = ?path, "read_write path does not exist and could not be created, skipping"); + continue; + } + let Some(path_str) = path.to_str() else { + tracing::warn!(path = ?path, "Skipping non-UTF8 read_write path"); + continue; + }; + caps = caps.allow_path(path_str, AccessMode::ReadWrite)?; + } + + // Device special files (character devices like /dev/null, /dev/tty, etc.). + for dev in DEVICE_FILES { + let p = Path::new(dev); + if !p.exists() { + continue; + } + if let Err(e) = caps.allow_file_mut(p, AccessMode::ReadWrite) { + tracing::warn!(path = dev, error = %e, "Could not allow device file"); + } + } + // Device directories (e.g. /dev/pts for PTY slaves on Linux). + for dev in DEVICE_DIRS { + let p = Path::new(dev); + if p.exists() && p.is_dir() { + caps = caps.allow_path(dev, AccessMode::ReadWrite)?; + } + } + + // Kernel deny (read+write): macOS Seatbelt rules; Linux via bwrap bind-over. + // The effective deny set is the profile's own `deny` (custom profiles only; + // built-ins carry an empty `deny`). An empty set means there is nothing to + // enforce. Keying on emptiness rather than profile type avoids enforcing + // unintentional denies. + // + // Split exact paths from globs: exact paths keep the literal/subpath flow; + // globs become anchored Seatbelt regexes on macOS (a no-op here on Linux, + // where they are expanded and bound over at bwrap re-exec). + let (exact_deny, glob_deny) = partition_deny_entries(&profile.deny); + let all_denied = effective_deny_paths(workspace, &exact_deny); + if !all_denied.is_empty() { + apply_deny_paths_to_capability_set(&mut caps, &all_denied)?; + } + if !glob_deny.is_empty() { + apply_deny_globs_to_capability_set(&mut caps, workspace, &glob_deny)?; + } + + Ok(caps) + } + + /// Resolve this profile into a fully-specified `SandboxProfile` for logging. + pub fn resolve_profile( + &self, + workspace: &Path, + config: &SandboxConfig, + ) -> anyhow::Result { + self.resolve(workspace, config) + } + + fn resolve(&self, workspace: &Path, config: &SandboxConfig) -> anyhow::Result { + match self { + // Selected `off` is handled before resolve (empty CapabilitySet / + // early return in apply). Reaching here is almost always a custom + // profile with `extends = "off"` / `"none"` — return Err, never panic. + Self::Off => anyhow::bail!( + "sandbox profile 'off' cannot be resolved as a base profile; \ + choose a built-in base (workspace, devbox, read-only, strict)" + ), + + Self::Workspace => Ok(SandboxProfile { + name: "workspace".to_string(), + read_only: vec![], + read_write: essential_writable_paths(workspace)?, + deny: vec![], + default_read: true, + restrict_network: false, + }), + + Self::Devbox => { + // Everything writable except /data. Enumerate top-level + // dirs and skip the exclusion list. Can't grant "/" because + // Landlock has no deny_path — sub-path exceptions are + // only possible by not granting the parent. + // + // /data is excluded from read_write here (so it is not writable) + // but is deliberately NOT a kernel-deny: it stays readable via + // default_read, and its Linux write-deny comes from the + // bwrap_reexec_command(&["/data"]) re-exec, not from profile.deny. + // Keeping deny empty stops a custom profile that extends devbox + // from inheriting /data into the enforced kernel-deny set. + let exclude = [PathBuf::from("/data")]; + let mut read_write = vec![workspace.to_path_buf()]; + if let Ok(entries) = std::fs::read_dir("/") { + for entry in entries.flatten() { + let path = entry.path(); + if exclude.contains(&path) { + continue; + } + // Skip virtual filesystems (handled separately) + if matches!(path.to_str(), Some("/proc" | "/sys" | "/dev")) { + continue; + } + if path.is_dir() { + read_write.push(path); + } + } + } + Ok(SandboxProfile { + name: "devbox".to_string(), + read_only: vec![], + read_write, + deny: vec![], + default_read: true, + restrict_network: false, + }) + } + + Self::ReadOnly => Ok(SandboxProfile { + name: "read-only".to_string(), + read_only: vec![], + read_write: essential_writable_paths_minimal()?, + deny: vec![], + default_read: true, + restrict_network: true, + }), + + Self::Strict => { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/root")); + let system_read: Vec = [ + "/usr", "/lib", "/lib64", "/bin", "/sbin", "/etc", "/dev", "/proc", "/sys", + "/tmp", + // Landlock realpath: /etc/resolv.conf often → /run/systemd/resolve/… + "/run", + // NSS/SSSD (and similar) under /var — needed beyond resolv.conf alone + "/var", + // macOS-specific paths (filtered by exists() below) + "/System", // Security framework, dylibs, TLS certificates + "/Library", // System-wide frameworks + "/private", // Real path behind /etc, /tmp, /var symlinks + ] + .iter() + .map(PathBuf::from) + .filter(|p| p.exists()) + // ~/Library is needed for macOS keychain access (TLS cert validation) + .chain(std::iter::once(home.join("Library"))) + .filter(|p| p.exists()) + .chain(std::iter::once(workspace.to_path_buf())) + .collect(); + + Ok(SandboxProfile { + name: "strict".to_string(), + read_only: system_read, + read_write: essential_writable_paths(workspace)?, + deny: vec![], + default_read: false, + restrict_network: true, + }) + } + + Self::Custom(name) => { + let profile_config = config.profiles.get(name).ok_or_else(|| { + anyhow::anyhow!( + "Custom sandbox profile '{name}' not found. \ + Define it in ~/.devo/sandbox.toml or .devo/sandbox.toml:\n\n\ + [profiles.{name}]\n\ + extends = \"workspace\"\n\ + read_only = [\"/data\"]\n" + ) + })?; + + // Start from the base profile if `extends` is set + let mut profile = if let Some(base_name) = &profile_config.extends { + let base: ProfileName = base_name.parse().map_err(|e: String| { + anyhow::anyhow!("Profile '{name}' extends invalid base: {e}") + })?; + if matches!(base, Self::Off) { + anyhow::bail!( + "Profile '{name}' extends '{base_name}', but 'off'/'none' \ + is not a valid base profile" + ); + } + if matches!(base, Self::Custom(_)) { + anyhow::bail!( + "Profile '{name}' extends '{base_name}', but custom profiles \ + cannot extend other custom profiles (only built-ins)" + ); + } + base.resolve(workspace, config)? + } else { + // Default: start from workspace + Self::Workspace.resolve(workspace, config)? + }; + + profile.name = name.clone(); + + // Apply overrides from the custom config + if let Some(restrict_net) = profile_config.restrict_network { + profile.restrict_network = restrict_net; + } + + // Add custom read-only paths + for path_str in &profile_config.read_only { + profile.read_only.push(PathBuf::from(path_str)); + } + + // Add custom read-write paths + for path_str in &profile_config.read_write { + profile.read_write.push(PathBuf::from(path_str)); + } + + // Add custom deny paths + for path_str in &profile_config.deny { + profile.deny.push(PathBuf::from(path_str)); + } + + Ok(profile) + } + } + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + static NEXT_TEST_CONFIG_DIR: AtomicUsize = AtomicUsize::new(0); + + struct TestConfigDir { + path: PathBuf, + } + + impl TestConfigDir { + fn new() -> Self { + let unique = NEXT_TEST_CONFIG_DIR.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "devo-sandbox-profiles-{}-{unique}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("create temporary config directory"); + Self { path } + } + + fn global_path(&self) -> PathBuf { + self.path.join("global-sandbox.toml") + } + + fn project_path(&self) -> PathBuf { + self.path.join("project-sandbox.toml") + } + } + + impl Drop for TestConfigDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } + } + + fn write_config(path: &Path, contents: &str) { + std::fs::write(path, contents).expect("write sandbox config"); + } + + #[test] + fn parse_profile_names() { + assert_eq!( + "workspace".parse::().unwrap(), + ProfileName::Workspace + ); + assert_eq!( + "devbox".parse::().unwrap(), + ProfileName::Devbox + ); + assert_eq!( + "read-only".parse::().unwrap(), + ProfileName::ReadOnly + ); + assert_eq!( + "readonly".parse::().unwrap(), + ProfileName::ReadOnly + ); + assert_eq!( + "strict".parse::().unwrap(), + ProfileName::Strict + ); + assert_eq!("off".parse::().unwrap(), ProfileName::Off); + assert_eq!("none".parse::().unwrap(), ProfileName::Off); + // Unknown names become Custom profiles + assert_eq!( + "my-custom-profile".parse::().unwrap(), + ProfileName::Custom("my-custom-profile".to_string()) + ); + } + + #[test] + fn display_roundtrip() { + for profile in [ + ProfileName::Workspace, + ProfileName::Devbox, + ProfileName::ReadOnly, + ProfileName::Strict, + ProfileName::Off, + ] { + let s = profile.to_string(); + let parsed: ProfileName = s.parse().unwrap(); + assert_eq!(parsed, profile); + } + } + + #[test] + fn display_custom() { + let p = ProfileName::Custom("my-custom".to_string()); + assert_eq!(p.to_string(), "my-custom"); + } + + #[test] + fn missing_config_files_are_absent() { + let test_dir = TestConfigDir::new(); + + let config = + load_sandbox_config_from_paths(&test_dir.global_path(), &test_dir.project_path()) + .expect("missing configuration files are optional"); + + assert_eq!(config, SandboxConfig::default()); + } + + #[test] + fn malformed_global_config_fails_before_project_merge() { + let test_dir = TestConfigDir::new(); + write_config(&test_dir.global_path(), "[profiles.secure"); + write_config( + &test_dir.project_path(), + "[profiles.project-only]\nextends = \"workspace\"\n", + ); + + let error = + load_sandbox_config_from_paths(&test_dir.global_path(), &test_dir.project_path()) + .expect_err("malformed global configuration must fail closed"); + + assert!( + error.to_string().contains("global sandbox config"), + "unexpected error: {error:#}" + ); + } + + #[test] + fn malformed_project_config_fails_explicitly() { + let test_dir = TestConfigDir::new(); + write_config( + &test_dir.global_path(), + "[profiles.secure]\nextends = \"workspace\"\n", + ); + write_config(&test_dir.project_path(), "[profiles.project"); + + let error = + load_sandbox_config_from_paths(&test_dir.global_path(), &test_dir.project_path()) + .expect_err("malformed project configuration must fail closed"); + + assert!( + error.to_string().contains("project sandbox config"), + "unexpected error: {error:#}" + ); + } + + #[test] + fn malformed_global_config_cannot_be_replaced_by_project_trusted_name() { + let test_dir = TestConfigDir::new(); + write_config(&test_dir.global_path(), "[profiles.secure"); + write_config( + &test_dir.project_path(), + "[profiles.secure]\nextends = \"workspace\"\nread_write = [\"/\"]\n", + ); + + let error = + load_sandbox_config_from_paths(&test_dir.global_path(), &test_dir.project_path()) + .expect_err( + "project must not replace a trusted profile after global loading fails", + ); + + assert!( + error.to_string().contains("global sandbox config"), + "unexpected error: {error:#}" + ); + } + + #[test] + fn unreadable_global_config_is_not_treated_as_absent() { + let test_dir = TestConfigDir::new(); + + let error = load_sandbox_config_from_paths(&test_dir.path, &test_dir.project_path()) + .expect_err("a directory cannot be read as a sandbox configuration file"); + + assert!( + error.to_string().contains("global sandbox config"), + "unexpected error: {error:#}" + ); + } + + #[test] + fn built_in_network_restriction_values() { + let workspace = std::env::current_dir().unwrap(); + let config = SandboxConfig::default(); + + for (name, expected) in [ + (ProfileName::Workspace, false), + (ProfileName::Devbox, false), + (ProfileName::ReadOnly, true), + (ProfileName::Strict, true), + ] { + let resolved = name.resolve_profile(&workspace, &config).unwrap(); + assert_eq!(resolved.restrict_network, expected, "{name}"); + } + } + + fn network_inheritance_config() -> SandboxConfig { + SandboxConfig { + profiles: HashMap::from([ + ( + "strict-inherited".to_string(), + ProfileConfig { + extends: Some("strict".to_string()), + restrict_network: None, + read_only: vec![], + read_write: vec![], + deny: vec![], + }, + ), + ( + "read-only-inherited".to_string(), + ProfileConfig { + extends: Some("read-only".to_string()), + restrict_network: None, + read_only: vec![], + read_write: vec![], + deny: vec![], + }, + ), + ( + "strict-unrestricted".to_string(), + ProfileConfig { + extends: Some("strict".to_string()), + restrict_network: Some(false), + read_only: vec![], + read_write: vec![], + deny: vec![], + }, + ), + ( + "workspace-restricted".to_string(), + ProfileConfig { + extends: Some("workspace".to_string()), + restrict_network: Some(true), + read_only: vec![], + read_write: vec![], + deny: vec![], + }, + ), + ]), + } + } + + #[test] + fn custom_network_restriction_inherits_and_overrides_base() { + let workspace = std::env::current_dir().unwrap(); + let config = network_inheritance_config(); + + for (name, expected) in [ + ("strict-inherited", true), + ("read-only-inherited", true), + ("strict-unrestricted", false), + ("workspace-restricted", true), + ] { + let profile_name = ProfileName::Custom(name.to_string()); + let resolved = profile_name.resolve_profile(&workspace, &config).unwrap(); + assert_eq!(resolved.restrict_network, expected, "{name}"); + } + } + + #[test] + #[cfg(all(feature = "enforce", unix))] + fn strict_allowlist_includes_run_and_var_when_present() { + // Regression: /run (resolv realpath) + /var (NSS/SSSD) when present. + let workspace = std::env::temp_dir(); + let profile = ProfileName::Strict + .resolve_profile(&workspace, &SandboxConfig::default()) + .expect("strict resolves"); + assert!(!profile.default_read); + if PathBuf::from("/run").exists() { + assert!( + profile.read_only.iter().any(|p| p == Path::new("/run")), + "strict read_only must include exact /run for systemd-resolved DNS; got {:?}", + profile.read_only + ); + } + if PathBuf::from("/var").exists() { + assert!( + profile.read_only.iter().any(|p| p == Path::new("/var")), + "strict read_only must include exact /var for NSS/SSSD; got {:?}", + profile.read_only + ); + } + } + + #[test] + #[cfg(all(feature = "enforce", unix))] + fn base_profile_capability_set_builds() { + // A base profile with no `deny` builds a CapabilitySet without erroring. + let workspace = std::env::current_dir().unwrap(); + let config = SandboxConfig::default(); + let result = ProfileName::Workspace.to_capability_set_with_config(&workspace, &config); + assert!(result.is_ok(), "Failed: {:?}", result.err()); + } + + #[test] + #[cfg(all(feature = "enforce", unix))] + fn custom_profile_from_config() { + let workspace = std::env::current_dir().unwrap(); + let config = SandboxConfig { + profiles: HashMap::from([( + "project".to_string(), + ProfileConfig { + extends: Some("workspace".to_string()), + restrict_network: Some(true), + read_only: vec!["/data".to_string()], + read_write: vec![], + deny: vec!["/data/private".to_string()], + }, + )]), + }; + + let profile = ProfileName::Custom("project".to_string()); + let result = profile.to_capability_set_with_config(&workspace, &config); + assert!(result.is_ok(), "Failed: {:?}", result.err()); + } + + #[test] + #[cfg(all(feature = "enforce", unix))] + fn custom_extends_devbox_has_no_data_in_deny() { + // Regression: devbox excludes /data via a local list, not profile.deny, so + // a custom profile extending devbox must not inherit /data into the kernel + // deny set (which would wrongly read-deny /data and force fail-closed). + let workspace = std::env::current_dir().unwrap(); + let config = SandboxConfig { + profiles: HashMap::from([( + "mydev".to_string(), + ProfileConfig { + extends: Some("devbox".to_string()), + restrict_network: None, + read_only: vec![], + read_write: vec![], + deny: vec![], + }, + )]), + }; + let profile = ProfileName::Custom("mydev".to_string()); + let resolved = profile.resolve_profile(&workspace, &config).unwrap(); + assert!( + !resolved.deny.contains(&PathBuf::from("/data")), + "custom profile extending devbox must not inherit /data into deny: {:?}", + resolved.deny + ); + } + + #[test] + #[cfg(all(feature = "enforce", unix))] + fn custom_profile_not_found() { + let workspace = std::env::current_dir().unwrap(); + let config = SandboxConfig::default(); + + let profile = ProfileName::Custom("nonexistent".to_string()); + let result = profile.to_capability_set_with_config(&workspace, &config); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("not found"), "Unexpected error: {err}"); + } + + #[test] + fn mismatched_profile_names_reports_only_changed_custom_profiles() { + let profile = |restrict_network| ProfileConfig { + extends: Some("workspace".to_string()), + restrict_network: Some(restrict_network), + read_only: vec![], + read_write: vec![], + deny: vec![], + }; + let global = SandboxConfig { + profiles: HashMap::from([ + ("dev".to_string(), profile(false)), + ("same".to_string(), profile(false)), + ]), + }; + let project = SandboxConfig { + profiles: HashMap::from([ + ("dev".to_string(), profile(true)), + ("same".to_string(), profile(false)), + ("project-only".to_string(), profile(true)), + ("devbox".to_string(), profile(true)), + ]), + }; + + assert_eq!(mismatched_profile_names(&global, &project), vec!["dev"]); + } + + #[test] + fn parse_toml_config() { + let toml_str = r#" +[profiles.devbox] +extends = "workspace" +restrict_network = true +read_only = ["/data"] +deny = ["/data/private"] + +[profiles.ci] +extends = "strict" +read_write = ["/tmp/ci-artifacts"] +"#; + let config: SandboxConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.profiles.len(), 2); + assert!(config.profiles.contains_key("devbox")); + assert!(config.profiles.contains_key("ci")); + assert_eq!(config.profiles["devbox"].read_only, vec!["/data"]); + assert_eq!(config.profiles["devbox"].deny, vec!["/data/private"]); + } + + #[test] + fn project_cannot_redefine_global_profile() { + // Global "secure" with a real deny list must win over a project hollow-out. + let mut config = SandboxConfig { + profiles: HashMap::from([( + "secure".to_string(), + ProfileConfig { + extends: Some("workspace".to_string()), + restrict_network: Some(true), + read_only: vec![], + read_write: vec![], + deny: vec!["/home/user/.ssh".to_string()], + }, + )]), + }; + let project = SandboxConfig { + profiles: HashMap::from([ + ( + "secure".to_string(), + ProfileConfig { + extends: Some("workspace".to_string()), + restrict_network: Some(false), + read_only: vec![], + read_write: vec!["/".to_string()], + deny: vec![], + }, + ), + ( + "project-only".to_string(), + ProfileConfig { + extends: Some("workspace".to_string()), + restrict_network: None, + read_only: vec![], + read_write: vec![], + deny: vec!["./secrets".to_string()], + }, + ), + ]), + }; + + merge_project_profiles(&mut config, project); + + assert_eq!( + config.profiles["secure"].deny, + vec!["/home/user/.ssh".to_string()], + "global deny must be preserved" + ); + assert_eq!(config.profiles["secure"].restrict_network, Some(true)); + assert!( + config.profiles["secure"].read_write.is_empty(), + "project must not widen global read_write" + ); + assert!( + config.profiles.contains_key("project-only"), + "new project-only profile names are still allowed" + ); + } + + #[test] + #[cfg(all(feature = "enforce", unix))] + fn extends_off_returns_err_not_panic() { + let workspace = std::env::current_dir().unwrap(); + let config = SandboxConfig { + profiles: HashMap::from([( + "broken".to_string(), + ProfileConfig { + extends: Some("off".to_string()), + restrict_network: None, + read_only: vec![], + read_write: vec![], + deny: vec![], + }, + )]), + }; + let err = ProfileName::Custom("broken".to_string()) + .resolve_profile(&workspace, &config) + .expect_err("extends=off must Err"); + let msg = err.to_string(); + assert!( + msg.contains("off") || msg.contains("none"), + "unexpected error: {msg}" + ); + } + + #[test] + #[cfg(all(feature = "enforce", unix))] + fn resolve_off_returns_err_not_panic() { + let workspace = std::env::current_dir().unwrap(); + let err = ProfileName::Off + .resolve_profile(&workspace, &SandboxConfig::default()) + .expect_err("Off.resolve must Err"); + assert!(err.to_string().contains("off"), "unexpected error: {err}"); + } +} diff --git a/crates/sandbox/src/types.rs b/crates/sandbox/src/types.rs new file mode 100644 index 00000000..42fb5b9f --- /dev/null +++ b/crates/sandbox/src/types.rs @@ -0,0 +1,193 @@ +//! Types for sandbox events, metrics, and profile configuration. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// A recorded sandbox event for telemetry and debugging. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SandboxEvent { + pub timestamp: DateTime, + pub event_type: SandboxEventType, + pub profile: String, + + // Context fields — present on ProfileApplied/ApplyFailed + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub platform: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub enforced: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub restrict_network: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub read_write_paths: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub read_only_paths: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub deny_paths: Option>, + + // Violation/error fields + #[serde(skip_serializing_if = "Option::is_none")] + pub operation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub target: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub command: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl SandboxEvent { + fn base(event_type: SandboxEventType, profile: &str) -> Self { + Self { + timestamp: Utc::now(), + event_type, + profile: profile.to_string(), + workspace: None, + platform: None, + enforced: None, + restrict_network: None, + read_write_paths: None, + read_only_paths: None, + deny_paths: None, + operation: None, + target: None, + command: None, + tool_call_id: None, + error: None, + } + } + + /// Create a "profile applied" event with full context. + pub fn profile_applied( + profile: &str, + workspace: &std::path::Path, + resolved: &crate::profiles::SandboxProfile, + ) -> Self { + let platform = if cfg!(target_os = "linux") { + "linux/landlock" + } else if cfg!(target_os = "macos") { + "macos/seatbelt" + } else { + "unknown" + }; + + let mut event = Self::base(SandboxEventType::ProfileApplied, profile); + event.workspace = Some(workspace.display().to_string()); + event.platform = Some(platform.to_string()); + event.enforced = Some(true); + event.restrict_network = Some(resolved.restrict_network); + event.read_write_paths = Some( + resolved + .read_write + .iter() + .map(|p| p.display().to_string()) + .collect(), + ); + if !resolved.read_only.is_empty() { + event.read_only_paths = Some( + resolved + .read_only + .iter() + .map(|p| p.display().to_string()) + .collect(), + ); + } + if !resolved.deny.is_empty() { + event.deny_paths = Some( + resolved + .deny + .iter() + .map(|p| p.display().to_string()) + .collect(), + ); + } + event + } + + /// Create an "apply failed" event with context. + pub fn apply_failed( + profile: &str, + workspace: &std::path::Path, + error: &dyn std::fmt::Display, + ) -> Self { + let platform = if cfg!(target_os = "linux") { + "linux/landlock" + } else if cfg!(target_os = "macos") { + "macos/seatbelt" + } else { + "unknown" + }; + + let mut event = Self::base(SandboxEventType::ApplyFailed, profile); + event.workspace = Some(workspace.display().to_string()); + event.platform = Some(platform.to_string()); + event.enforced = Some(false); + event.error = Some(error.to_string()); + event + } + + /// Create a filesystem violation event. + pub fn fs_violation(profile: &str, target: &str, operation: &str) -> Self { + let mut event = Self::base(SandboxEventType::FsViolation, profile); + event.operation = Some(operation.to_string()); + event.target = Some(target.to_string()); + event + } + + /// Create a network violation event. + pub fn net_violation(profile: &str, target: &str) -> Self { + let mut event = Self::base(SandboxEventType::NetViolation, profile); + event.operation = Some("connect".to_string()); + event.target = Some(target.to_string()); + event + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum SandboxEventType { + ProfileApplied, + ApplyFailed, + FsViolation, + NetViolation, + BypassGranted, + BypassDenied, +} + +/// Counters for sandbox activity, used for telemetry dashboards. +#[derive(Debug, Default)] +pub struct SandboxMetrics { + pub fs_violations: AtomicU64, + pub net_violations: AtomicU64, + pub bypasses_granted: AtomicU64, + pub bypasses_denied: AtomicU64, +} + +impl SandboxMetrics { + pub fn inc_fs_violation(&self) { + self.fs_violations.fetch_add(1, Ordering::Relaxed); + } + + pub fn inc_net_violation(&self) { + self.net_violations.fetch_add(1, Ordering::Relaxed); + } + + pub fn inc_bypass_granted(&self) { + self.bypasses_granted.fetch_add(1, Ordering::Relaxed); + } + + pub fn inc_bypass_denied(&self) { + self.bypasses_denied.fetch_add(1, Ordering::Relaxed); + } + + pub fn fs_violation_count(&self) -> u64 { + self.fs_violations.load(Ordering::Relaxed) + } + + pub fn net_violation_count(&self) -> u64 { + self.net_violations.load(Ordering::Relaxed) + } +} diff --git a/crates/sandbox/tests/deny_paths_e2e.rs b/crates/sandbox/tests/deny_paths_e2e.rs new file mode 100644 index 00000000..d4207b7d --- /dev/null +++ b/crates/sandbox/tests/deny_paths_e2e.rs @@ -0,0 +1,451 @@ +//! E2E enforcement tests for kernel-enforced profile `deny` paths. +//! +//! Drives the GENERIC path-deny primitive via a custom sandbox profile whose +//! `deny` list names concrete files. `SandboxManager::apply` is process-wide and +//! irreversible, so kernel enforcement is verified in an isolated subprocess. +//! +//! On Linux, read-deny requires bwrap bind-over; the subprocess re-execs inside +//! bwrap when `bwrap` is available. macOS uses Seatbelt platform rules directly +//! via `SandboxManager::apply`. + +#![cfg(all(unix, feature = "enforce"))] + +use std::fs; +use std::path::Path; +use std::process::Command; + +const SCENARIO_ENV: &str = "SANDBOX_E2E_SCENARIO"; +const WORKSPACE_ENV: &str = "SANDBOX_E2E_WORKSPACE"; +/// Custom profile name, comma-joined deny targets, and comma-joined control +/// files, passed to the subprocess so one entry point drives every deny case +/// (exact paths and globs alike). +const PROFILE_ENV: &str = "SANDBOX_E2E_PROFILE"; +const TARGETS_ENV: &str = "SANDBOX_E2E_TARGETS"; +const CONTROLS_ENV: &str = "SANDBOX_E2E_CONTROLS"; +/// Paths NOT present at apply time that match a deny glob; the macOS runtime +/// regex must deny creating them post-launch (the differentiator vs exact paths). +const POSTLAUNCH_ENV: &str = "SANDBOX_E2E_POSTLAUNCH"; +const MARKER: &str = "deny-paths-e2e-marker-9f3c1a"; + +/// Re-invoke this test binary as a subprocess driving `profile` over `targets` +/// (denied) and `controls` (must stay readable). `postlaunch` paths are created +/// AFTER apply to exercise the macOS runtime-regex (post-launch) coverage. +fn run_scenario( + workspace: &Path, + profile: &str, + targets: &[&str], + controls: &[&str], + postlaunch: &[&str], +) -> (std::process::ExitStatus, String) { + let exe = std::env::current_exe().expect("current_exe"); + let output = Command::new(exe) + .env(SCENARIO_ENV, "block_deny") + .env(WORKSPACE_ENV, workspace.as_os_str()) + .env(PROFILE_ENV, profile) + .env(TARGETS_ENV, targets.join(",")) + .env(CONTROLS_ENV, controls.join(",")) + .env(POSTLAUNCH_ENV, postlaunch.join(",")) + .arg("--ignored") + .arg("--exact") + .arg("--nocapture") + .arg("subprocess_entry") + .output() + .expect("failed to spawn subprocess"); + // All assertions read stderr; the subprocess prints only diagnostics there. + ( + output.status, + String::from_utf8_lossy(&output.stderr).into_owned(), + ) +} + +/// Decode a comma-joined env list (empty/missing -> empty vec). +fn list_from_env(key: &str) -> Vec { + std::env::var(key) + .ok() + .map(|v| { + v.split(',') + .filter(|s| !s.is_empty()) + .map(String::from) + .collect() + }) + .unwrap_or_default() +} + +// EROFS too: a root writer on Linux bypasses the mode-000 DAC check via +// CAP_DAC_OVERRIDE and hits the read-only bind-mount instead — still a denial. +fn is_permission_denied(e: &std::io::Error) -> bool { + matches!( + e.raw_os_error(), + Some(libc::EACCES) | Some(libc::EPERM) | Some(libc::EROFS) + ) +} + +/// Spawn a child command and `exit(1)` if its stdout exposes the secret MARKER. +/// Asserts marker-absence rather than a non-zero exit: a root reader of the +/// mode-000 placeholder gets empty output, which still means the path is shadowed. +fn assert_child_cannot_read(label: &str, program: &str, args: &[&str]) { + let out = Command::new(program) + .args(args) + .output() + .unwrap_or_else(|e| panic!("failed to spawn {program}: {e}")); + if String::from_utf8_lossy(&out.stdout).contains(MARKER) { + eprintln!("FAIL: {label} exposed MARKER"); + std::process::exit(1); + } +} + +/// Assert a denied file's bytes are unreadable via an in-process read, a `cat` +/// child (the `bash`/`grep` tools), and a nested `sh -c "cat"` child (the shell a +/// subagent shells out through). The property is MARKER-absence (EACCES/EPERM, or +/// empty output under root, all satisfy it). +fn assert_read_blocked(label: &str, path: &Path) { + if let Ok(content) = fs::read_to_string(path) + && content.contains(MARKER) + { + eprintln!("FAIL: {label} in-process read exposed MARKER"); + std::process::exit(1); + } + let s = path.display().to_string(); + assert_child_cannot_read(label, "cat", &[s.as_str()]); + let sh_cmd = format!("cat '{s}'"); + assert_child_cannot_read(label, "sh", &["-c", sh_cmd.as_str()]); + eprintln!("OK: {label} read blocked"); +} + +/// Assert a denied file cannot be overwritten (write must EACCES/EPERM, not +/// succeed — a permitted write would enable the relocation bypass below). +fn assert_write_denied(label: &str, path: &Path) { + match fs::write(path, "overwrite-attempt") { + Err(e) if is_permission_denied(&e) => eprintln!("OK: {label} write denied"), + Err(e) => { + eprintln!("FAIL: unexpected {label} write error: {e}"); + std::process::exit(1); + } + Ok(()) => { + eprintln!("FAIL: {label} write was permitted (relocation bypass possible)"); + std::process::exit(1); + } + } +} + +/// Assert the `mv x y && cat y` relocation bypass does not expose the bytes: +/// the rename must fail (unlink of the source is denied) so the moved copy never +/// materializes with the secret. +fn assert_rename_bypass_blocked(label: &str, path: &Path, workspace: &Path) { + let name = path.file_name().unwrap().to_string_lossy(); + let moved = workspace.join(format!("exfil-{name}")); + let _ = fs::rename(path, &moved); // expected to fail; bytes must not leak + match fs::read_to_string(&moved) { + Ok(c) if c.contains(MARKER) => { + eprintln!("FAIL: {label} rename bypass exposed MARKER"); + std::process::exit(1); + } + _ => eprintln!("OK: {label} rename bypass blocked"), + } +} + +#[cfg(target_os = "linux")] +fn bwrap_available() -> bool { + // `--version` only checks the binary exists; remote CI may have bwrap but + // deny user namespace creation ("Creating new namespace failed: Operation not permitted"). + Command::new("bwrap") + .args(["--bind", "/", "/", "--", "true"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// The custom profile under test, read from the env the parent set. +fn profile_from_env() -> devo_sandbox::ProfileName { + devo_sandbox::ProfileName::Custom(std::env::var(PROFILE_ENV).expect(PROFILE_ENV)) +} + +// ── Subprocess entry point ────────────────────────────────────────────── + +/// `#[ignore]`d — only runs when invoked by the parent test via `run_scenario`. +#[test] +#[ignore] +fn subprocess_entry() { + let scenario = match std::env::var(SCENARIO_ENV) { + Ok(s) => s, + Err(_) => return, + }; + let workspace = std::env::var(WORKSPACE_ENV).expect(WORKSPACE_ENV); + let workspace = dunce::canonicalize(&workspace).expect("canonicalize workspace"); + let workspace = workspace.as_path(); + let targets = list_from_env(TARGETS_ENV); + let controls = list_from_env(CONTROLS_ENV); + + #[cfg(target_os = "linux")] + { + if !devo_sandbox::is_inside_bwrap() { + // Drive the REAL routing the shell uses at startup — computing the + // custom profile's deny set (exact paths AND launch-time glob + // expansion), building placeholders, and failing closed on a partial + // bind — rather than hand-rolling a single-path `bwrap_reexec_command`. + match devo_sandbox::bwrap_reexec_for_profile(&profile_from_env(), workspace) { + Ok(Some(mut cmd)) => { + use std::os::unix::process::CommandExt; + let err = cmd.exec(); // returns only if exec failed + eprintln!("bwrap re-exec failed: {err}"); + std::process::exit(2); + } + // Outside bwrap with no command means the read-deny set could not + // be secured. The shell fails closed here; mirror that. + Ok(None) => { + eprintln!("FAIL: bwrap_reexec_for_profile returned None outside bwrap"); + std::process::exit(2); + } + Err(error) => { + eprintln!("FAIL: bwrap_reexec_for_profile failed closed: {error}"); + std::process::exit(2); + } + } + } + } + + match scenario.as_str() { + "block_deny" => { + let mut sandbox = devo_sandbox::SandboxManager::new(profile_from_env(), workspace); + if let Err(e) = sandbox.apply(workspace) { + eprintln!("sandbox apply failed: {e}"); + std::process::exit(3); + } + if !sandbox.is_applied() { + eprintln!("sandbox was not applied (unsupported platform?)"); + std::process::exit(4); + } + + // Each denied target must be read-, write-, and rename-denied — via the + // read_file tool (in-process), `bash`/`grep` (cat child), and the shell + // a subagent uses (sh -c child). Targets exercise nested glob matches + // (`sub/dir/key.pem`) and the denied-directory (subpath) path alike. + for rel in &targets { + let path = workspace.join(rel); + assert_read_blocked(rel, &path); + assert_write_denied(rel, &path); + assert_rename_bypass_blocked(rel, &path, workspace); + } + + // Non-denied control files (incl. a sibling of a glob match) stay readable. + for rel in &controls { + match fs::read_to_string(workspace.join(rel)) { + Ok(c) if c.contains("hello") => eprintln!("OK: {rel} control readable"), + Ok(_) => { + eprintln!("FAIL: control {rel} readable but missing marker"); + std::process::exit(1); + } + Err(e) => { + eprintln!("FAIL: control {rel} should stay readable: {e}"); + std::process::exit(1); + } + } + } + + // macOS-only: the runtime regex denies paths that match a glob even + // when created AFTER apply — the differentiator vs the exact-path flow + // (and the macOS-airtight half of the documented asymmetry). On Linux + // post-launch matches are best-effort and NOT covered, so skip there. + #[cfg(target_os = "macos")] + for rel in list_from_env(POSTLAUNCH_ENV) { + match fs::write(workspace.join(&rel), MARKER) { + Err(e) if is_permission_denied(&e) => { + eprintln!("OK: {rel} post-launch write denied") + } + Err(e) => { + eprintln!("FAIL: unexpected {rel} post-launch write error: {e}"); + std::process::exit(1); + } + Ok(()) => { + eprintln!("FAIL: {rel} post-launch matching path was writable"); + std::process::exit(1); + } + } + } + // A NON-matching post-launch path must still be writable — proves the + // denial above is the glob, not a blanket workspace write-deny. + #[cfg(target_os = "macos")] + if !list_from_env(POSTLAUNCH_ENV).is_empty() { + match fs::write(workspace.join("late-control.txt"), "hello") { + Ok(()) => eprintln!("OK: post-launch control writable"), + Err(e) => { + eprintln!("FAIL: non-matching post-launch path should be writable: {e}"); + std::process::exit(1); + } + } + } + + std::process::exit(0); + } + other => { + eprintln!("unknown scenario: {other}"); + std::process::exit(99); + } + } +} + +// ── Parent test cases ─────────────────────────────────────────────────── + +/// Drive one deny case end-to-end: define a custom profile whose `deny` list is +/// `deny_entries` (exact paths and/or globs), create each `target` (with the +/// MARKER) and each `control` (readable), then assert in an isolated subprocess +/// that every target is read/write/rename-denied and every control stays +/// readable. Shared by the exact-path and glob cases. +fn run_deny_case( + tag: &str, + profile: &str, + deny_entries: &[&str], + targets: &[&str], + controls: &[&str], + postlaunch: &[&str], +) { + // When set, missing prerequisites must FAIL loudly instead of skipping, so a + // CI lane can guarantee the deny enforcement is actually exercised. + let require = std::env::var("SANDBOX_E2E_REQUIRE_ENFORCEMENT").is_ok(); + + let support = devo_sandbox::SandboxManager::support_info(); + if !support.is_supported { + if require { + panic!( + "SANDBOX_E2E_REQUIRE_ENFORCEMENT set but sandbox unsupported: {}", + support.details + ); + } + eprintln!("skipping: sandbox not supported ({})", support.details); + return; + } + + #[cfg(target_os = "linux")] + if !bwrap_available() { + if require { + panic!( + "SANDBOX_E2E_REQUIRE_ENFORCEMENT set but bwrap unavailable (required for Linux read-deny)" + ); + } + eprintln!("skipping: bwrap not installed (required for Linux read-deny)"); + return; + } + + let tmp = std::env::temp_dir().join(format!( + "devo-sandbox-e2e-{tag}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&tmp).expect("create temp workspace"); + let tmp = dunce::canonicalize(&tmp).expect("canonicalize temp workspace"); + let _cleanup = TempDirGuard(tmp.clone()); + + // Define the custom profile whose `deny` list holds the entries under test. + let deny_list = deny_entries + .iter() + .map(|p| format!("\"{p}\"")) + .collect::>() + .join(", "); + fs::create_dir_all(tmp.join(".devo")).expect("mkdir .devo"); + fs::write( + tmp.join(".devo").join("sandbox.toml"), + format!("[profiles.{profile}]\nextends = \"workspace\"\ndeny = [{deny_list}]\n"), + ) + .expect("write sandbox.toml"); + + // Create each denied target with the MARKER (parents created as needed, e.g. + // `sub/dir/` for a nested glob match, `secretdir/` for a denied directory) + // plus each readable control. + for rel in targets { + let path = tmp.join(rel); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("mkdir denied parent"); + } + fs::write(&path, format!("SECRET={MARKER}")).expect("write denied file"); + } + for rel in controls { + let path = tmp.join(rel); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("mkdir control parent"); + } + fs::write(&path, "hello workspace").expect("write control"); + } + + let (status, stderr) = run_scenario(&tmp, profile, targets, controls, postlaunch); + assert!( + status.success(), + "[{tag}] custom-profile deny should block read/write/rename\nstderr: {stderr}" + ); + for rel in targets { + assert!( + stderr.contains(&format!("OK: {rel} read blocked")), + "[{tag}] expected '{rel}' read block confirmation\nstderr: {stderr}" + ); + assert!( + stderr.contains(&format!("OK: {rel} write denied")), + "[{tag}] expected '{rel}' write to be denied\nstderr: {stderr}" + ); + assert!( + stderr.contains(&format!("OK: {rel} rename bypass blocked")), + "[{tag}] expected '{rel}' rename bypass to be blocked\nstderr: {stderr}" + ); + } + for rel in controls { + assert!( + stderr.contains(&format!("OK: {rel} control readable")), + "[{tag}] expected non-denied control '{rel}' to stay readable\nstderr: {stderr}" + ); + } + // The post-launch (runtime-regex) coverage is macOS-only; Linux best-effort + // expansion does not cover files created after launch. + #[cfg(target_os = "macos")] + for rel in postlaunch { + assert!( + stderr.contains(&format!("OK: {rel} post-launch write denied")), + "[{tag}] expected post-launch matching '{rel}' to be write-denied\nstderr: {stderr}" + ); + } + #[cfg(target_os = "macos")] + if !postlaunch.is_empty() { + assert!( + stderr.contains("OK: post-launch control writable"), + "[{tag}] expected non-matching post-launch path to stay writable\nstderr: {stderr}" + ); + } +} + +#[test] +fn deny_exact_paths_block_read_write_rename() { + // Exact-path entries: two files plus a directory (exercised via a file inside + // it), covering the literal-file and the subpath / Linux dir-placeholder paths. + run_deny_case( + "exact", + "denytest", + &[".env", "src/server.pem", "secretdir"], + &[".env", "src/server.pem", "secretdir/inner.pem"], + &["readable.txt"], + &[], // exact paths have no runtime/post-launch coverage to assert + ); +} + +#[test] +fn deny_globs_block_read_write_rename() { + // Glob entries exercising: nested `*.pem`, a `.env` at root AND nested, and a + // trailing-`**` prefix dir. The control inside a matched directory + // (`sub/dir/keep.txt`) proves the glob denies only matches, not the whole tree. + // `postlaunch` (`late.pem`) pins the macOS runtime-regex post-launch coverage. + run_deny_case( + "glob", + "denyglob", + &["**/*.pem", "**/.env", "secrets/**"], + &["sub/dir/key.pem", ".env", "sub/.env", "secrets/inner.key"], + &["readable.txt", "sub/dir/keep.txt"], + &["late.pem"], + ); +} + +struct TempDirGuard(std::path::PathBuf); + +impl Drop for TempDirGuard { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} diff --git a/crates/sandbox/tests/integration_test.rs b/crates/sandbox/tests/integration_test.rs new file mode 100644 index 00000000..841e7a13 --- /dev/null +++ b/crates/sandbox/tests/integration_test.rs @@ -0,0 +1,107 @@ +//! Integration tests for devo-sandbox. +//! +//! Note: `Sandbox::apply()` is irreversible and process-wide, so we cannot +//! test actual kernel enforcement in standard `#[test]` functions (they share +//! a process). The end-to-end suite uses subprocesses for enforcement testing. +//! These tests verify the API contracts, config loading, and support detection. + +use pretty_assertions::assert_eq; + +// `support_info` is only available with the `enforce` feature (it returns a +// nono type), so gate this test the same way. +#[test] +#[cfg(all(feature = "enforce", unix))] +fn test_support_info() { + // Verify that nono can report platform support status without applying + let support = devo_sandbox::SandboxManager::support_info(); + // On macOS and Linux 5.13+, this should be supported + // On other platforms, it gracefully reports unsupported + println!( + "Sandbox support: supported={}, details={}", + support.is_supported, support.details + ); + // We don't assert is_supported because CI may run on any platform +} + +// `to_capability_set` is only available with the `enforce` feature. +#[test] +#[cfg(all(feature = "enforce", unix))] +fn test_profile_capability_set_construction() { + use devo_sandbox::ProfileName; + + // Use CWD as workspace — guaranteed to exist + let workspace = std::env::current_dir().expect("cwd"); + + // All profiles should produce valid CapabilitySets without panicking + for profile in [ + ProfileName::Workspace, + ProfileName::ReadOnly, + ProfileName::Strict, + ProfileName::Off, + ] { + let result = profile.to_capability_set(&workspace); + assert!( + result.is_ok(), + "Profile {:?} failed to build CapabilitySet: {:?}", + profile, + result.err() + ); + } +} + +#[test] +fn test_sandbox_manager_lifecycle() { + use devo_sandbox::{ProfileName, SandboxManager}; + + let workspace = std::env::current_dir().expect("cwd"); + + // Off profile: apply should succeed without actually sandboxing + let mut manager = SandboxManager::new(ProfileName::Off, &workspace); + assert!(!manager.is_applied()); + assert!(!manager.restrict_child_network()); + + let result = manager.apply(&workspace); + assert!(result.is_ok()); + // Off profile doesn't actually apply + assert!(!manager.is_applied()); +} + +#[test] +fn test_sandbox_logger() { + use devo_sandbox::{SandboxEvent, SandboxLogger}; + + let logger = SandboxLogger::new(); + + // Log some events (use violation events — profile_applied requires a resolved profile) + logger.log(SandboxEvent::fs_violation("workspace", "/tmp/test", "read")); + logger.log(SandboxEvent::fs_violation( + "workspace", + "/etc/shadow", + "write", + )); + logger.log(SandboxEvent::net_violation("strict", "evil.com:443")); + + // Check metrics + assert_eq!(logger.metrics().fs_violation_count(), 2); + assert_eq!(logger.metrics().net_violation_count(), 1); + + // Take events drains the buffer + let events = logger.take_events(); + assert_eq!(events.len(), 3); + + // Buffer is now empty + let events2 = logger.take_events(); + assert!(events2.is_empty()); +} + +#[test] +fn test_should_restrict_child_network_default() { + // Before any sandbox is applied, child network should not be restricted + // Note: this test may interfere with other tests if they set the global. + // In practice, the global is set once at process startup and never unset. + // For testing, we just verify the default state. + // + // We can't meaningfully test the "set" path without applying a sandbox + // (which is irreversible), so we verify the default is false. + assert!(!devo_sandbox::should_restrict_child_network()); +} diff --git a/crates/server/skills/.system/skill-creator/SKILL.md b/crates/server/skills/.system/skill-creator/SKILL.md index 57f4e58b..8c97efd5 100644 --- a/crates/server/skills/.system/skill-creator/SKILL.md +++ b/crates/server/skills/.system/skill-creator/SKILL.md @@ -1,6 +1,6 @@ --- name: skill-creator -description: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Codex's capabilities with specialized knowledge, workflows, or tool integrations. +description: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends the agent's capabilities with specialized knowledge, workflows, or tool integrations. metadata: short-description: Create or update a skill --- @@ -11,9 +11,9 @@ This skill provides guidance for creating effective skills. ## About Skills -Skills are modular, self-contained folders that extend Codex's capabilities by providing +Skills are modular, self-contained folders that extend the agent's capabilities by providing specialized knowledge, workflows, and tools. Think of them as "onboarding guides" for specific -domains or tasks—they transform Codex from a general-purpose agent into a specialized agent +domains or tasks—they transform the agent from a general-purpose agent into a specialized agent equipped with procedural knowledge that no model can fully possess. ### What Skills Provide @@ -27,9 +27,9 @@ equipped with procedural knowledge that no model can fully possess. ### Concise is Key -The context window is a public good. Skills share the context window with everything else Codex needs: system prompt, conversation history, other Skills' metadata, and the actual user request. +The context window is a public good. Skills share the context window with everything else the agent needs: system prompt, conversation history, other Skills' metadata, and the actual user request. -**Default assumption: Codex is already very smart.** Only add context Codex doesn't already have. Challenge each piece of information: "Does Codex really need this explanation?" and "Does this paragraph justify its token cost?" +**Default assumption: the agent is already very smart.** Only add context the agent doesn't already have. Challenge each piece of information: "Does the agent really need this explanation?" and "Does this paragraph justify its token cost?" Prefer concise examples over verbose explanations. @@ -43,7 +43,7 @@ Match the level of specificity to the task's fragility and variability: **Low freedom (specific scripts, few parameters)**: Use when operations are fragile and error-prone, consistency is critical, or a specific sequence must be followed. -Think of Codex as exploring a path: a narrow bridge with cliffs needs specific guardrails (low freedom), while an open field allows many routes (high freedom). +Think of the agent as exploring a path: a narrow bridge with cliffs needs specific guardrails (low freedom), while an open field allows many routes (high freedom). ### Protect Validation Integrity @@ -76,7 +76,7 @@ skill-name/ Every SKILL.md consists of: -- **Frontmatter** (YAML): Contains `name` and `description` fields. These are the only fields that Codex reads to determine when the skill gets used, thus it is very important to be clear and comprehensive in describing what the skill is, and when it should be used. +- **Frontmatter** (YAML): Contains `name` and `description` fields. These are the only fields that the agent reads to determine when the skill gets used, thus it is very important to be clear and comprehensive in describing what the skill is, and when it should be used. - **Body** (Markdown): Instructions and guidance for using the skill. Only loaded AFTER the skill triggers (if at all). #### Agents metadata (recommended) @@ -98,27 +98,27 @@ Executable code (Python/Bash/etc.) for tasks that require deterministic reliabil - **When to include**: When the same code is being rewritten repeatedly or deterministic reliability is needed - **Example**: `scripts/rotate_pdf.py` for PDF rotation tasks - **Benefits**: Token efficient, deterministic, may be executed without loading into context -- **Note**: Scripts may still need to be read by Codex for patching or environment-specific adjustments +- **Note**: Scripts may still need to be read by the agent for patching or environment-specific adjustments ##### References (`references/`) -Documentation and reference material intended to be loaded as needed into context to inform Codex's process and thinking. +Documentation and reference material intended to be loaded as needed into context to inform the agent's process and thinking. -- **When to include**: For documentation that Codex should reference while working +- **When to include**: For documentation that the agent should reference while working - **Examples**: `references/finance.md` for financial schemas, `references/mnda.md` for company NDA template, `references/policies.md` for company policies, `references/api_docs.md` for API specifications - **Use cases**: Database schemas, API documentation, domain knowledge, company policies, detailed workflow guides -- **Benefits**: Keeps SKILL.md lean, loaded only when Codex determines it's needed +- **Benefits**: Keeps SKILL.md lean, loaded only when the agent determines it's needed - **Best practice**: If files are large (>10k words), include grep search patterns in SKILL.md - **Avoid duplication**: Information should live in either SKILL.md or references files, not both. Prefer references files for detailed information unless it's truly core to the skill—this keeps SKILL.md lean while making information discoverable without hogging the context window. Keep only essential procedural instructions and workflow guidance in SKILL.md; move detailed reference material, schemas, and examples to references files. ##### Assets (`assets/`) -Files not intended to be loaded into context, but rather used within the output Codex produces. +Files not intended to be loaded into context, but rather used within the output the agent produces. - **When to include**: When the skill needs files that will be used in the final output - **Examples**: `assets/logo.png` for brand assets, `assets/slides.pptx` for PowerPoint templates, `assets/frontend-template/` for HTML/React boilerplate, `assets/font.ttf` for typography - **Use cases**: Templates, images, icons, boilerplate code, fonts, sample documents that get copied or modified -- **Benefits**: Separates output resources from documentation, enables Codex to use files without loading them into context +- **Benefits**: Separates output resources from documentation, enables the agent to use files without loading them into context #### What to Not Include in a Skill @@ -138,7 +138,7 @@ Skills use a three-level loading system to manage context efficiently: 1. **Metadata (name + description)** - Always in context (~100 words) 2. **SKILL.md body** - When skill triggers (<5k words) -3. **Bundled resources** - As needed by Codex (Unlimited because scripts can be executed without reading into context window) +3. **Bundled resources** - As needed by the agent (Unlimited because scripts can be executed without reading into context window) #### Progressive Disclosure Patterns @@ -163,7 +163,7 @@ Extract text with pdfplumber: - **Examples**: See [EXAMPLES.md](EXAMPLES.md) for common patterns ``` -Codex loads FORMS.md, REFERENCE.md, or EXAMPLES.md only when needed. +the agent loads FORMS.md, REFERENCE.md, or EXAMPLES.md only when needed. **Pattern 2: Domain-specific organization** @@ -179,7 +179,7 @@ bigquery-skill/ └── marketing.md (campaigns, attribution) ``` -When a user asks about sales metrics, Codex only reads sales.md. +When a user asks about sales metrics, the agent only reads sales.md. Similarly, for skills supporting multiple frameworks or variants, organize by variant: @@ -192,7 +192,7 @@ cloud-deploy/ └── azure.md (Azure deployment patterns) ``` -When the user chooses AWS, Codex only reads aws.md. +When the user chooses AWS, the agent only reads aws.md. **Pattern 3: Conditional details** @@ -213,12 +213,12 @@ For simple edits, modify the XML directly. **For OOXML details**: See [OOXML.md](OOXML.md) ``` -Codex reads REDLINING.md or OOXML.md only when the user needs those features. +the agent reads REDLINING.md or OOXML.md only when the user needs those features. **Important guidelines:** - **Avoid deeply nested references** - Keep references one level deep from SKILL.md. All reference files should link directly from SKILL.md. -- **Structure longer reference files** - For files longer than 100 lines, include a table of contents at the top so Codex can see the full scope when previewing. +- **Structure longer reference files** - For files longer than 100 lines, include a table of contents at the top so the agent can see the full scope when previewing. ## Skill Creation Process @@ -253,7 +253,7 @@ For example, when building an image-editor skill, relevant questions include: - "Can you give some examples of how this skill would be used?" - "I can imagine users asking for things like 'Remove the red-eye from this image' or 'Rotate this image'. Are there other ways you imagine this skill being used?" - "What would a user say that should trigger this skill?" -- "Where should I create this skill? If you do not have a preference, I will place it in `$CODEX_HOME/skills` (or `~/.codex/skills` when `CODEX_HOME` is unset) so Codex can discover it automatically." +- "Where should I create this skill? If you do not have a preference, I will place it in `$DEVO_HOME/skills` (or `~/.devo/skills` when `DEVO_HOME` is unset) so the agent can discover it automatically." To avoid overwhelming users, avoid asking too many questions in a single message. Start with the most important questions and follow up as needed for better effectiveness. @@ -289,7 +289,7 @@ At this point, it is time to actually create the skill. Skip this step only if the skill being developed already exists. In this case, continue to the next step. -Before running `init_skill.py`, ask where the user wants the skill created. If they do not specify a location, default to `$CODEX_HOME/skills`; when `CODEX_HOME` is unset, fall back to `~/.codex/skills` so the skill is auto-discovered. +Before running `init_skill.py`, ask where the user wants the skill created. If they do not specify a location, default to `$DEVO_HOME/skills`; when `DEVO_HOME` is unset, fall back to `~/.devo/skills` so the skill is auto-discovered. When creating a new skill from scratch, always run the `init_skill.py` script. The script conveniently generates a new template skill directory that automatically includes everything a skill requires, making the skill creation process much more efficient and reliable. @@ -302,8 +302,8 @@ scripts/init_skill.py --path [--resources script Examples: ```bash -scripts/init_skill.py my-skill --path "${CODEX_HOME:-$HOME/.codex}/skills" -scripts/init_skill.py my-skill --path "${CODEX_HOME:-$HOME/.codex}/skills" --resources scripts,references +scripts/init_skill.py my-skill --path "${DEVO_HOME:-$HOME/.devo}/skills" +scripts/init_skill.py my-skill --path "${DEVO_HOME:-$HOME/.devo}/skills" --resources scripts,references scripts/init_skill.py my-skill --path ~/work/skills --resources scripts --examples ``` @@ -327,7 +327,7 @@ Only include other optional interface fields when the user explicitly provides t ### Step 4: Edit the Skill -When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of Codex to use. Include information that would be beneficial and non-obvious to Codex. Consider what procedural knowledge, domain-specific details, or reusable assets would help another Codex instance execute these tasks more effectively. +When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of the agent to use. Include information that would be beneficial and non-obvious to the agent. Consider what procedural knowledge, domain-specific details, or reusable assets would help another the agent instance execute these tasks more effectively. After substantial revisions, or if the skill is particularly tricky, you should use subagents to forward-test the skill on realistic tasks or artifacts. When doing so, pass the artifact under validation rather than your diagnosis of what is wrong, and keep the prompt generic enough that success depends on transferable reasoning rather than hidden ground truth. @@ -348,10 +348,10 @@ If you used `--examples`, delete any placeholder files that are not needed for t Write the YAML frontmatter with `name` and `description`: - `name`: The skill name -- `description`: This is the primary triggering mechanism for your skill, and helps Codex understand when to use the skill. +- `description`: This is the primary triggering mechanism for your skill, and helps the agent understand when to use the skill. - Include both what the Skill does and specific triggers/contexts for when to use it. - - Include all "when to use" information here - Not in the body. The body is only loaded after triggering, so "When to Use This Skill" sections in the body are not helpful to Codex. - - Example description for a `docx` skill: "Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. Use when Codex needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks" + - Include all "when to use" information here - Not in the body. The body is only loaded after triggering, so "When to Use This Skill" sections in the body are not helpful to the agent. + - Example description for a `docx` skill: "Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. Use when the agent needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks" Do not include any other fields in YAML frontmatter. diff --git a/crates/server/skills/.system/skill-creator/scripts/init_skill.py b/crates/server/skills/.system/skill-creator/scripts/init_skill.py index 69673eaa..a1ec38b8 100644 --- a/crates/server/skills/.system/skill-creator/scripts/init_skill.py +++ b/crates/server/skills/.system/skill-creator/scripts/init_skill.py @@ -83,20 +83,20 @@ **Appropriate for:** Python scripts, shell scripts, or any executable code that performs automation, data processing, or specific operations. -**Note:** Scripts may be executed without loading into context, but can still be read by Codex for patching or environment adjustments. +**Note:** Scripts may be executed without loading into context, but can still be read by the agent for patching or environment adjustments. ### references/ -Documentation and reference material intended to be loaded into context to inform Codex's process and thinking. +Documentation and reference material intended to be loaded into context to inform the agent's process and thinking. **Examples from other skills:** - Product management: `communication.md`, `context_building.md` - detailed workflow guides - BigQuery: API reference documentation and query examples - Finance: Schema documentation, company policies -**Appropriate for:** In-depth documentation, API references, database schemas, comprehensive guides, or any detailed information that Codex should reference while working. +**Appropriate for:** In-depth documentation, API references, database schemas, comprehensive guides, or any detailed information that the agent should reference while working. ### assets/ -Files not intended to be loaded into context, but rather used within the output Codex produces. +Files not intended to be loaded into context, but rather used within the output the agent produces. **Examples from other skills:** - Brand styling: PowerPoint template files (.pptx), logo files @@ -173,7 +173,7 @@ def main(): Replace with actual asset files (templates, images, fonts, etc.) or delete if not needed. Asset files are NOT intended to be loaded into context, but rather used within -the output Codex produces. +the output the agent produces. Example asset files from other skills: - Brand guidelines: logo.png, slides_template.pptx diff --git a/crates/server/skills/.system/skill-installer/SKILL.md b/crates/server/skills/.system/skill-installer/SKILL.md index 313626ac..2076e2df 100644 --- a/crates/server/skills/.system/skill-installer/SKILL.md +++ b/crates/server/skills/.system/skill-installer/SKILL.md @@ -1,6 +1,6 @@ --- name: skill-installer -description: Install Codex skills into $CODEX_HOME/skills from a curated list or a GitHub repo path. Use when a user asks to list installable skills, install a curated skill, or install a skill from another repo (including private repos). +description: Install the agent skills into $DEVO_HOME/skills from a curated list or a GitHub repo path. Use when a user asks to list installable skills, install a curated skill, or install a skill from another repo (including private repos). metadata: short-description: Install curated skills from openai/skills or other repos --- @@ -27,7 +27,7 @@ Skills from {repo}: Which ones would you like installed? """ -After installing a skill, tell the user: "Restart Codex to pick up new skills." +After installing a skill, tell the user: "Restart the agent to pick up new skills." ## Scripts @@ -45,7 +45,7 @@ All of these scripts use network, so when running in the sandbox, request escala - Defaults to direct download for public GitHub repos. - If download fails with auth/permission errors, falls back to git sparse checkout. - Aborts if the destination skill directory already exists. -- Installs into `$CODEX_HOME/skills/` (defaults to `~/.codex/skills`). +- Installs into `$DEVO_HOME/skills/` (defaults to `~/.devo/skills`). - Multiple `--path` values install multiple skills in one run, each named from the path basename unless `--name` is supplied. - Options: `--ref ` (default `main`), `--dest `, `--method auto|download|git`. @@ -55,4 +55,4 @@ All of these scripts use network, so when running in the sandbox, request escala - Private GitHub repos can be accessed via existing git credentials or optional `GITHUB_TOKEN`/`GH_TOKEN` for download. - Git fallback tries HTTPS first, then SSH. - The skills at https://github.com/openai/skills/tree/main/skills/.system are preinstalled, so no need to help users install those. If they ask, just explain this. If they insist, you can download and overwrite. -- Installed annotations come from `$CODEX_HOME/skills`. +- Installed annotations come from `$DEVO_HOME/skills`. diff --git a/crates/server/skills/.system/skill-installer/scripts/install-skill-from-github.py b/crates/server/skills/.system/skill-installer/scripts/install-skill-from-github.py index 1c8ce89d..cc93b057 100644 --- a/crates/server/skills/.system/skill-installer/scripts/install-skill-from-github.py +++ b/crates/server/skills/.system/skill-installer/scripts/install-skill-from-github.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Install a skill from a GitHub repo path into $CODEX_HOME/skills.""" +"""Install a skill from a GitHub repo path into $DEVO_HOME/skills.""" from __future__ import annotations @@ -42,18 +42,18 @@ class InstallError(Exception): pass -def _codex_home() -> str: - return os.environ.get("CODEX_HOME", os.path.expanduser("~/.codex")) +def _devo_home() -> str: + return os.environ.get("DEVO_HOME", os.path.expanduser("~/.devo")) def _tmp_root() -> str: - base = os.path.join(tempfile.gettempdir(), "codex") + base = os.path.join(tempfile.gettempdir(), "devo") os.makedirs(base, exist_ok=True) return base def _request(url: str) -> bytes: - return github_request(url, "codex-skill-install") + return github_request(url, "devo-skill-install") def _parse_github_url(url: str, default_ref: str) -> tuple[str, str, str, str | None]: @@ -241,7 +241,7 @@ def _resolve_source(args: Args) -> Source: def _default_dest() -> str: - return os.path.join(_codex_home(), "skills") + return os.path.join(_devo_home(), "skills") def _parse_args(argv: list[str]) -> Args: diff --git a/crates/server/skills/.system/skill-installer/scripts/list-skills.py b/crates/server/skills/.system/skill-installer/scripts/list-skills.py index 0977c296..0952d699 100644 --- a/crates/server/skills/.system/skill-installer/scripts/list-skills.py +++ b/crates/server/skills/.system/skill-installer/scripts/list-skills.py @@ -28,15 +28,15 @@ class Args(argparse.Namespace): def _request(url: str) -> bytes: - return github_request(url, "codex-skill-list") + return github_request(url, "devo-skill-list") -def _codex_home() -> str: - return os.environ.get("CODEX_HOME", os.path.expanduser("~/.codex")) +def _devo_home() -> str: + return os.environ.get("DEVO_HOME", os.path.expanduser("~/.devo")) def _installed_skills() -> set[str]: - root = os.path.join(_codex_home(), "skills") + root = os.path.join(_devo_home(), "skills") if not os.path.isdir(root): return set() entries = set() diff --git a/crates/server/src/runtime/approval.rs b/crates/server/src/runtime/approval.rs index e0ad1113..6c31db4b 100644 --- a/crates/server/src/runtime/approval.rs +++ b/crates/server/src/runtime/approval.rs @@ -240,12 +240,12 @@ impl ServerRuntime { ItemKind::ApprovalDecision, TurnItem::ApprovalDecision(ApprovalDecisionItem { approval_id: approval_id.clone(), - decision: format!("auto_review_{decision}"), + decision: decision.to_string(), scope: "auto_review".to_string(), }), serde_json::json!({ "approval_id": approval_id, - "decision": format!("auto_review_{decision}"), + "decision": decision, "scope": "auto_review", "rationale": rationale, "tool_name": request.tool_name, diff --git a/crates/server/src/runtime/handlers/session.rs b/crates/server/src/runtime/handlers/session.rs index ed7836e8..a1d87491 100644 --- a/crates/server/src/runtime/handlers/session.rs +++ b/crates/server/src/runtime/handlers/session.rs @@ -403,15 +403,8 @@ impl ServerRuntime { } // Persist updated session metadata to SQLite - if !summary.ephemeral - && let Err(err) = self.deps.db.upsert_session(&summary, None) - { - tracing::warn!( - session_id = %params.session_id, - error = %err, - "failed to update session title in database" - ); - } + self.persist_session_summary_if_persistent(params.session_id, &summary) + .await; self.broadcast_event(ServerEvent::SessionTitleUpdated(SessionEventPayload { session: summary.clone(), diff --git a/crates/server/src/runtime/items.rs b/crates/server/src/runtime/items.rs index 9a412b46..69768e7d 100644 --- a/crates/server/src/runtime/items.rs +++ b/crates/server/src/runtime/items.rs @@ -101,6 +101,25 @@ impl ServerRuntime { }); } + /// Persist session summary to SQLite if the session is durable. + /// The rollout file is the authoritative store, so failures here are + /// logged as warnings rather than propagated. + pub(super) async fn persist_session_summary_if_persistent( + &self, + session_id: SessionId, + summary: &SessionMetadata, + ) { + if !summary.ephemeral + && let Err(err) = self.deps.db.upsert_session(summary, None) + { + tracing::warn!( + session_id = %session_id, + error = %err, + "failed to persist session metadata to database" + ); + } + } + pub(super) async fn maybe_assign_provisional_title( &self, session_id: SessionId, @@ -144,6 +163,9 @@ impl ServerRuntime { tracing::warn!(session_id = %session_id, error = %error, "failed to persist provisional title"); } + self.persist_session_summary_if_persistent(session_id, &updated_summary) + .await; + self.broadcast_event(ServerEvent::SessionTitleUpdated(SessionEventPayload { session: updated_summary, })) @@ -262,6 +284,9 @@ impl ServerRuntime { tracing::warn!(session_id = %session_id, error = %error, "failed to persist title"); } + self.persist_session_summary_if_persistent(session_id, &updated_summary) + .await; + self.broadcast_event(ServerEvent::SessionTitleUpdated(SessionEventPayload { session: updated_summary, })) diff --git a/crates/skills/src/assets/samples/skill-creator/SKILL.md b/crates/skills/src/assets/samples/skill-creator/SKILL.md index 57f4e58b..8c97efd5 100644 --- a/crates/skills/src/assets/samples/skill-creator/SKILL.md +++ b/crates/skills/src/assets/samples/skill-creator/SKILL.md @@ -1,6 +1,6 @@ --- name: skill-creator -description: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Codex's capabilities with specialized knowledge, workflows, or tool integrations. +description: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends the agent's capabilities with specialized knowledge, workflows, or tool integrations. metadata: short-description: Create or update a skill --- @@ -11,9 +11,9 @@ This skill provides guidance for creating effective skills. ## About Skills -Skills are modular, self-contained folders that extend Codex's capabilities by providing +Skills are modular, self-contained folders that extend the agent's capabilities by providing specialized knowledge, workflows, and tools. Think of them as "onboarding guides" for specific -domains or tasks—they transform Codex from a general-purpose agent into a specialized agent +domains or tasks—they transform the agent from a general-purpose agent into a specialized agent equipped with procedural knowledge that no model can fully possess. ### What Skills Provide @@ -27,9 +27,9 @@ equipped with procedural knowledge that no model can fully possess. ### Concise is Key -The context window is a public good. Skills share the context window with everything else Codex needs: system prompt, conversation history, other Skills' metadata, and the actual user request. +The context window is a public good. Skills share the context window with everything else the agent needs: system prompt, conversation history, other Skills' metadata, and the actual user request. -**Default assumption: Codex is already very smart.** Only add context Codex doesn't already have. Challenge each piece of information: "Does Codex really need this explanation?" and "Does this paragraph justify its token cost?" +**Default assumption: the agent is already very smart.** Only add context the agent doesn't already have. Challenge each piece of information: "Does the agent really need this explanation?" and "Does this paragraph justify its token cost?" Prefer concise examples over verbose explanations. @@ -43,7 +43,7 @@ Match the level of specificity to the task's fragility and variability: **Low freedom (specific scripts, few parameters)**: Use when operations are fragile and error-prone, consistency is critical, or a specific sequence must be followed. -Think of Codex as exploring a path: a narrow bridge with cliffs needs specific guardrails (low freedom), while an open field allows many routes (high freedom). +Think of the agent as exploring a path: a narrow bridge with cliffs needs specific guardrails (low freedom), while an open field allows many routes (high freedom). ### Protect Validation Integrity @@ -76,7 +76,7 @@ skill-name/ Every SKILL.md consists of: -- **Frontmatter** (YAML): Contains `name` and `description` fields. These are the only fields that Codex reads to determine when the skill gets used, thus it is very important to be clear and comprehensive in describing what the skill is, and when it should be used. +- **Frontmatter** (YAML): Contains `name` and `description` fields. These are the only fields that the agent reads to determine when the skill gets used, thus it is very important to be clear and comprehensive in describing what the skill is, and when it should be used. - **Body** (Markdown): Instructions and guidance for using the skill. Only loaded AFTER the skill triggers (if at all). #### Agents metadata (recommended) @@ -98,27 +98,27 @@ Executable code (Python/Bash/etc.) for tasks that require deterministic reliabil - **When to include**: When the same code is being rewritten repeatedly or deterministic reliability is needed - **Example**: `scripts/rotate_pdf.py` for PDF rotation tasks - **Benefits**: Token efficient, deterministic, may be executed without loading into context -- **Note**: Scripts may still need to be read by Codex for patching or environment-specific adjustments +- **Note**: Scripts may still need to be read by the agent for patching or environment-specific adjustments ##### References (`references/`) -Documentation and reference material intended to be loaded as needed into context to inform Codex's process and thinking. +Documentation and reference material intended to be loaded as needed into context to inform the agent's process and thinking. -- **When to include**: For documentation that Codex should reference while working +- **When to include**: For documentation that the agent should reference while working - **Examples**: `references/finance.md` for financial schemas, `references/mnda.md` for company NDA template, `references/policies.md` for company policies, `references/api_docs.md` for API specifications - **Use cases**: Database schemas, API documentation, domain knowledge, company policies, detailed workflow guides -- **Benefits**: Keeps SKILL.md lean, loaded only when Codex determines it's needed +- **Benefits**: Keeps SKILL.md lean, loaded only when the agent determines it's needed - **Best practice**: If files are large (>10k words), include grep search patterns in SKILL.md - **Avoid duplication**: Information should live in either SKILL.md or references files, not both. Prefer references files for detailed information unless it's truly core to the skill—this keeps SKILL.md lean while making information discoverable without hogging the context window. Keep only essential procedural instructions and workflow guidance in SKILL.md; move detailed reference material, schemas, and examples to references files. ##### Assets (`assets/`) -Files not intended to be loaded into context, but rather used within the output Codex produces. +Files not intended to be loaded into context, but rather used within the output the agent produces. - **When to include**: When the skill needs files that will be used in the final output - **Examples**: `assets/logo.png` for brand assets, `assets/slides.pptx` for PowerPoint templates, `assets/frontend-template/` for HTML/React boilerplate, `assets/font.ttf` for typography - **Use cases**: Templates, images, icons, boilerplate code, fonts, sample documents that get copied or modified -- **Benefits**: Separates output resources from documentation, enables Codex to use files without loading them into context +- **Benefits**: Separates output resources from documentation, enables the agent to use files without loading them into context #### What to Not Include in a Skill @@ -138,7 +138,7 @@ Skills use a three-level loading system to manage context efficiently: 1. **Metadata (name + description)** - Always in context (~100 words) 2. **SKILL.md body** - When skill triggers (<5k words) -3. **Bundled resources** - As needed by Codex (Unlimited because scripts can be executed without reading into context window) +3. **Bundled resources** - As needed by the agent (Unlimited because scripts can be executed without reading into context window) #### Progressive Disclosure Patterns @@ -163,7 +163,7 @@ Extract text with pdfplumber: - **Examples**: See [EXAMPLES.md](EXAMPLES.md) for common patterns ``` -Codex loads FORMS.md, REFERENCE.md, or EXAMPLES.md only when needed. +the agent loads FORMS.md, REFERENCE.md, or EXAMPLES.md only when needed. **Pattern 2: Domain-specific organization** @@ -179,7 +179,7 @@ bigquery-skill/ └── marketing.md (campaigns, attribution) ``` -When a user asks about sales metrics, Codex only reads sales.md. +When a user asks about sales metrics, the agent only reads sales.md. Similarly, for skills supporting multiple frameworks or variants, organize by variant: @@ -192,7 +192,7 @@ cloud-deploy/ └── azure.md (Azure deployment patterns) ``` -When the user chooses AWS, Codex only reads aws.md. +When the user chooses AWS, the agent only reads aws.md. **Pattern 3: Conditional details** @@ -213,12 +213,12 @@ For simple edits, modify the XML directly. **For OOXML details**: See [OOXML.md](OOXML.md) ``` -Codex reads REDLINING.md or OOXML.md only when the user needs those features. +the agent reads REDLINING.md or OOXML.md only when the user needs those features. **Important guidelines:** - **Avoid deeply nested references** - Keep references one level deep from SKILL.md. All reference files should link directly from SKILL.md. -- **Structure longer reference files** - For files longer than 100 lines, include a table of contents at the top so Codex can see the full scope when previewing. +- **Structure longer reference files** - For files longer than 100 lines, include a table of contents at the top so the agent can see the full scope when previewing. ## Skill Creation Process @@ -253,7 +253,7 @@ For example, when building an image-editor skill, relevant questions include: - "Can you give some examples of how this skill would be used?" - "I can imagine users asking for things like 'Remove the red-eye from this image' or 'Rotate this image'. Are there other ways you imagine this skill being used?" - "What would a user say that should trigger this skill?" -- "Where should I create this skill? If you do not have a preference, I will place it in `$CODEX_HOME/skills` (or `~/.codex/skills` when `CODEX_HOME` is unset) so Codex can discover it automatically." +- "Where should I create this skill? If you do not have a preference, I will place it in `$DEVO_HOME/skills` (or `~/.devo/skills` when `DEVO_HOME` is unset) so the agent can discover it automatically." To avoid overwhelming users, avoid asking too many questions in a single message. Start with the most important questions and follow up as needed for better effectiveness. @@ -289,7 +289,7 @@ At this point, it is time to actually create the skill. Skip this step only if the skill being developed already exists. In this case, continue to the next step. -Before running `init_skill.py`, ask where the user wants the skill created. If they do not specify a location, default to `$CODEX_HOME/skills`; when `CODEX_HOME` is unset, fall back to `~/.codex/skills` so the skill is auto-discovered. +Before running `init_skill.py`, ask where the user wants the skill created. If they do not specify a location, default to `$DEVO_HOME/skills`; when `DEVO_HOME` is unset, fall back to `~/.devo/skills` so the skill is auto-discovered. When creating a new skill from scratch, always run the `init_skill.py` script. The script conveniently generates a new template skill directory that automatically includes everything a skill requires, making the skill creation process much more efficient and reliable. @@ -302,8 +302,8 @@ scripts/init_skill.py --path [--resources script Examples: ```bash -scripts/init_skill.py my-skill --path "${CODEX_HOME:-$HOME/.codex}/skills" -scripts/init_skill.py my-skill --path "${CODEX_HOME:-$HOME/.codex}/skills" --resources scripts,references +scripts/init_skill.py my-skill --path "${DEVO_HOME:-$HOME/.devo}/skills" +scripts/init_skill.py my-skill --path "${DEVO_HOME:-$HOME/.devo}/skills" --resources scripts,references scripts/init_skill.py my-skill --path ~/work/skills --resources scripts --examples ``` @@ -327,7 +327,7 @@ Only include other optional interface fields when the user explicitly provides t ### Step 4: Edit the Skill -When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of Codex to use. Include information that would be beneficial and non-obvious to Codex. Consider what procedural knowledge, domain-specific details, or reusable assets would help another Codex instance execute these tasks more effectively. +When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of the agent to use. Include information that would be beneficial and non-obvious to the agent. Consider what procedural knowledge, domain-specific details, or reusable assets would help another the agent instance execute these tasks more effectively. After substantial revisions, or if the skill is particularly tricky, you should use subagents to forward-test the skill on realistic tasks or artifacts. When doing so, pass the artifact under validation rather than your diagnosis of what is wrong, and keep the prompt generic enough that success depends on transferable reasoning rather than hidden ground truth. @@ -348,10 +348,10 @@ If you used `--examples`, delete any placeholder files that are not needed for t Write the YAML frontmatter with `name` and `description`: - `name`: The skill name -- `description`: This is the primary triggering mechanism for your skill, and helps Codex understand when to use the skill. +- `description`: This is the primary triggering mechanism for your skill, and helps the agent understand when to use the skill. - Include both what the Skill does and specific triggers/contexts for when to use it. - - Include all "when to use" information here - Not in the body. The body is only loaded after triggering, so "When to Use This Skill" sections in the body are not helpful to Codex. - - Example description for a `docx` skill: "Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. Use when Codex needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks" + - Include all "when to use" information here - Not in the body. The body is only loaded after triggering, so "When to Use This Skill" sections in the body are not helpful to the agent. + - Example description for a `docx` skill: "Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. Use when the agent needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks" Do not include any other fields in YAML frontmatter. diff --git a/crates/skills/src/assets/samples/skill-creator/scripts/init_skill.py b/crates/skills/src/assets/samples/skill-creator/scripts/init_skill.py index 69673eaa..a1ec38b8 100644 --- a/crates/skills/src/assets/samples/skill-creator/scripts/init_skill.py +++ b/crates/skills/src/assets/samples/skill-creator/scripts/init_skill.py @@ -83,20 +83,20 @@ **Appropriate for:** Python scripts, shell scripts, or any executable code that performs automation, data processing, or specific operations. -**Note:** Scripts may be executed without loading into context, but can still be read by Codex for patching or environment adjustments. +**Note:** Scripts may be executed without loading into context, but can still be read by the agent for patching or environment adjustments. ### references/ -Documentation and reference material intended to be loaded into context to inform Codex's process and thinking. +Documentation and reference material intended to be loaded into context to inform the agent's process and thinking. **Examples from other skills:** - Product management: `communication.md`, `context_building.md` - detailed workflow guides - BigQuery: API reference documentation and query examples - Finance: Schema documentation, company policies -**Appropriate for:** In-depth documentation, API references, database schemas, comprehensive guides, or any detailed information that Codex should reference while working. +**Appropriate for:** In-depth documentation, API references, database schemas, comprehensive guides, or any detailed information that the agent should reference while working. ### assets/ -Files not intended to be loaded into context, but rather used within the output Codex produces. +Files not intended to be loaded into context, but rather used within the output the agent produces. **Examples from other skills:** - Brand styling: PowerPoint template files (.pptx), logo files @@ -173,7 +173,7 @@ def main(): Replace with actual asset files (templates, images, fonts, etc.) or delete if not needed. Asset files are NOT intended to be loaded into context, but rather used within -the output Codex produces. +the output the agent produces. Example asset files from other skills: - Brand guidelines: logo.png, slides_template.pptx diff --git a/crates/skills/src/assets/samples/skill-installer/SKILL.md b/crates/skills/src/assets/samples/skill-installer/SKILL.md index 313626ac..2076e2df 100644 --- a/crates/skills/src/assets/samples/skill-installer/SKILL.md +++ b/crates/skills/src/assets/samples/skill-installer/SKILL.md @@ -1,6 +1,6 @@ --- name: skill-installer -description: Install Codex skills into $CODEX_HOME/skills from a curated list or a GitHub repo path. Use when a user asks to list installable skills, install a curated skill, or install a skill from another repo (including private repos). +description: Install the agent skills into $DEVO_HOME/skills from a curated list or a GitHub repo path. Use when a user asks to list installable skills, install a curated skill, or install a skill from another repo (including private repos). metadata: short-description: Install curated skills from openai/skills or other repos --- @@ -27,7 +27,7 @@ Skills from {repo}: Which ones would you like installed? """ -After installing a skill, tell the user: "Restart Codex to pick up new skills." +After installing a skill, tell the user: "Restart the agent to pick up new skills." ## Scripts @@ -45,7 +45,7 @@ All of these scripts use network, so when running in the sandbox, request escala - Defaults to direct download for public GitHub repos. - If download fails with auth/permission errors, falls back to git sparse checkout. - Aborts if the destination skill directory already exists. -- Installs into `$CODEX_HOME/skills/` (defaults to `~/.codex/skills`). +- Installs into `$DEVO_HOME/skills/` (defaults to `~/.devo/skills`). - Multiple `--path` values install multiple skills in one run, each named from the path basename unless `--name` is supplied. - Options: `--ref ` (default `main`), `--dest `, `--method auto|download|git`. @@ -55,4 +55,4 @@ All of these scripts use network, so when running in the sandbox, request escala - Private GitHub repos can be accessed via existing git credentials or optional `GITHUB_TOKEN`/`GH_TOKEN` for download. - Git fallback tries HTTPS first, then SSH. - The skills at https://github.com/openai/skills/tree/main/skills/.system are preinstalled, so no need to help users install those. If they ask, just explain this. If they insist, you can download and overwrite. -- Installed annotations come from `$CODEX_HOME/skills`. +- Installed annotations come from `$DEVO_HOME/skills`. diff --git a/crates/skills/src/assets/samples/skill-installer/scripts/install-skill-from-github.py b/crates/skills/src/assets/samples/skill-installer/scripts/install-skill-from-github.py index 1c8ce89d..cc93b057 100644 --- a/crates/skills/src/assets/samples/skill-installer/scripts/install-skill-from-github.py +++ b/crates/skills/src/assets/samples/skill-installer/scripts/install-skill-from-github.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Install a skill from a GitHub repo path into $CODEX_HOME/skills.""" +"""Install a skill from a GitHub repo path into $DEVO_HOME/skills.""" from __future__ import annotations @@ -42,18 +42,18 @@ class InstallError(Exception): pass -def _codex_home() -> str: - return os.environ.get("CODEX_HOME", os.path.expanduser("~/.codex")) +def _devo_home() -> str: + return os.environ.get("DEVO_HOME", os.path.expanduser("~/.devo")) def _tmp_root() -> str: - base = os.path.join(tempfile.gettempdir(), "codex") + base = os.path.join(tempfile.gettempdir(), "devo") os.makedirs(base, exist_ok=True) return base def _request(url: str) -> bytes: - return github_request(url, "codex-skill-install") + return github_request(url, "devo-skill-install") def _parse_github_url(url: str, default_ref: str) -> tuple[str, str, str, str | None]: @@ -241,7 +241,7 @@ def _resolve_source(args: Args) -> Source: def _default_dest() -> str: - return os.path.join(_codex_home(), "skills") + return os.path.join(_devo_home(), "skills") def _parse_args(argv: list[str]) -> Args: diff --git a/crates/skills/src/assets/samples/skill-installer/scripts/list-skills.py b/crates/skills/src/assets/samples/skill-installer/scripts/list-skills.py index 0977c296..0952d699 100644 --- a/crates/skills/src/assets/samples/skill-installer/scripts/list-skills.py +++ b/crates/skills/src/assets/samples/skill-installer/scripts/list-skills.py @@ -28,15 +28,15 @@ class Args(argparse.Namespace): def _request(url: str) -> bytes: - return github_request(url, "codex-skill-list") + return github_request(url, "devo-skill-list") -def _codex_home() -> str: - return os.environ.get("CODEX_HOME", os.path.expanduser("~/.codex")) +def _devo_home() -> str: + return os.environ.get("DEVO_HOME", os.path.expanduser("~/.devo")) def _installed_skills() -> set[str]: - root = os.path.join(_codex_home(), "skills") + root = os.path.join(_devo_home(), "skills") if not os.path.isdir(root): return set() entries = set() diff --git a/crates/tui/src/bottom_pane/approval_overlay.rs b/crates/tui/src/bottom_pane/approval_overlay.rs index 8f18c8e0..4dbe271c 100644 --- a/crates/tui/src/bottom_pane/approval_overlay.rs +++ b/crates/tui/src/bottom_pane/approval_overlay.rs @@ -170,10 +170,8 @@ fn build_header(request: &ApprovalOverlayRequest) -> ColumnRenderable<'static> { header.push(Line::from(request.action_summary.clone()).bold()); header.push(Line::from("")); push_field(&mut header, "reason", Some(&request.justification)); - push_field(&mut header, "resource", request.resource.as_ref()); push_field(&mut header, "path", request.path.as_ref()); push_field(&mut header, "host", request.host.as_ref()); - push_field(&mut header, "target", request.target.as_ref()); header } diff --git a/crates/tui/src/chatwidget/configuration.rs b/crates/tui/src/chatwidget/configuration.rs index 1e10b050..2a3733ed 100644 --- a/crates/tui/src/chatwidget/configuration.rs +++ b/crates/tui/src/chatwidget/configuration.rs @@ -419,7 +419,7 @@ impl ChatWidget { self.bottom_pane .open_popup_view(Box::new(ListSelectionView::new( SelectionViewParams { - title: Some("Update Model Permissions".to_string()), + title: Some("Update Permissions".to_string()), footer_hint: Some(Line::from("Press enter to confirm or esc to go back")), items: permission_preset_items(current), ..SelectionViewParams::default() diff --git a/crates/tui/src/chatwidget/permission_presets.rs b/crates/tui/src/chatwidget/permission_presets.rs index c2c09ea1..07b9d127 100644 --- a/crates/tui/src/chatwidget/permission_presets.rs +++ b/crates/tui/src/chatwidget/permission_presets.rs @@ -11,25 +11,20 @@ use crate::bottom_pane::list_selection_view::SelectionItem; pub(super) fn permission_preset_items(current: PermissionPreset) -> Vec { [ - ( - PermissionPreset::ReadOnly, - "Read Only", - "Devo can read files in the current workspace. Approval is required to edit files, run commands, or access the internet.", - ), ( PermissionPreset::Default, "Default", - "Devo can read and edit files in the current workspace, and run commands. Approval is required to access the internet or edit other files.", + "Read, write, and run commands in workspace; network blocked.", ), ( PermissionPreset::AutoReview, "Auto-review", - "Same workspace-write permissions as Default, but eligible approvals are routed through the auto-reviewer before interrupting you.", + "Same as Default, but auto-reviewer handles approvals first.", ), ( PermissionPreset::FullAccess, "Full Access", - "Devo can edit files outside this workspace and access the internet without asking for approval. Exercise caution when using.", + "Full access — no approval needed; use with cautio.", ), ] .into_iter() @@ -39,9 +34,7 @@ pub(super) fn permission_preset_items(current: PermissionPreset) -> Vec(s).ok()); + let expected = serde_json::json!({"text": "a:b,c", "quote": "\"x,y\""}); + assert_eq!(actual, Some(expected)); } #[test] diff --git a/docs/superpowers/plans/2026-07-12-desktop-reference-search-and-session-refill.md b/docs/superpowers/plans/2026-07-12-desktop-reference-search-and-session-refill.md deleted file mode 100644 index 0fee666f..00000000 --- a/docs/superpowers/plans/2026-07-12-desktop-reference-search-and-session-refill.md +++ /dev/null @@ -1,130 +0,0 @@ -# Desktop Reference Search and Session Refill Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make Desktop `@` search expose server-provided Skills and MCPs, and keep a paginated project session list full after a deletion. - -**Architecture:** Preserve the server-owned `search/*` ranking and pass the complete typed result snapshot through the renderer hook into the mention popover. For deletion, update the connection manager's discovery cache at the same boundary that refills the current pagination window, then invoke that boundary after a confirmed delete. - -**Tech Stack:** TypeScript, React, Jotai, Bun tests, Devo Desktop SDK - -## Global Constraints - -- Work in `/Users/tsiao/Desktop/devo` and preserve all unrelated dirty-worktree changes. -- Keep inline Desktop icons consistent with `apps/desktop/AGENTS.md` (`size-3.5 stroke-[1.5]`). -- Do not stage or commit changes unless the user asks. - ---- - -### Task 1: Preserve and render all server reference-search result kinds - -**Files:** -- Create: `apps/desktop/src/renderer/hooks/use-reference-search.ts` -- Delete: `apps/desktop/src/renderer/hooks/use-file-search.ts` -- Modify: `apps/desktop/src/renderer/components/chat/mention-popover.tsx` -- Modify: `apps/desktop/src/renderer/components/chat/prompt-mentions.ts` -- Modify: `apps/desktop/src/renderer/components/chat/context-items.tsx` -- Modify: `apps/desktop/src/renderer/components/new-chat.tsx` -- Modify: `apps/desktop/src/renderer/components/chat/chat-input.tsx` -- Modify: `apps/desktop/src/renderer/components/chat/chat-view.tsx` -- Test: `apps/desktop/src/renderer/components/chat/mention-popover.test.ts` - -**Interfaces:** -- Consumes: `ReferenceSearchSnapshot.results: ReferenceSearchResult[]` from `@devo-ai/sdk/v2/client`. -- Produces: `useReferenceSearch(...): { results, isLoading, error }` and mention options for `skill`, `mcp`, `file`, and local `agent` entries. - -- [x] **Step 1: Write a failing result-mapping test** - -Assert that a snapshot containing Skill, MCP, and File results maps all three into Desktop mention options, retains each server `insert_text`, and preserves disabled metadata. - -- [x] **Step 2: Run the focused test and verify the file-only implementation fails** - -Run: `bun test src/renderer/components/chat/mention-popover.test.ts` - -Expected: FAIL because the full-result mapping API and Skill/MCP options do not exist. - -- [x] **Step 3: Replace the file-only hook with a full reference-result hook** - -Subscribe to the existing connection-local search session, store `snapshot.results` without filtering by kind, and retain the existing debounce/cancel/error behavior. - -- [x] **Step 4: Add typed Skill and MCP popover groups and selection behavior** - -Map the server result fields directly, render category-appropriate icons/labels/descriptions, prevent disabled results from being selected, and insert the exact server `insert_text` token while retaining mention tracking. - -- [x] **Step 5: Run the focused renderer test** - -Run: `bun test src/renderer/components/chat/mention-popover.test.ts` - -Expected: PASS. - -### Task 2: Refill the current session page after deletion - -**Files:** -- Modify: `apps/desktop/src/renderer/services/connection-manager.ts` -- Modify: `apps/desktop/src/renderer/components/sidebar-layout.tsx` -- Test: `apps/desktop/src/renderer/services/connection-manager.test.ts` - -**Interfaces:** -- Consumes: `projectPaginationFamily(directory).currentLimit` and the discovery-session cache. -- Produces: `refillProjectSessionsAfterDelete(projectDirectory: string, sessionId: string): Promise`. - -- [x] **Step 1: Write a failing pagination regression test** - -Seed six root sessions, load a five-session window, delete one visible session, and assert that the sixth session is hydrated while `currentLimit` remains five. - -- [x] **Step 2: Run the focused test and verify it fails** - -Run: `bun test src/renderer/services/connection-manager.test.ts` - -Expected: FAIL because deletion neither updates the discovery cache nor refills the current window. - -- [x] **Step 3: Implement the cache-aware refill boundary** - -Remove the deleted ID from renderer state and `discoveredSessions`, read the existing project limit, and reload the same root-session window so the next hidden session fills the gap without making “Show more” jump an extra page. - -- [x] **Step 4: Invoke refill after a confirmed sidebar deletion** - -Call the connection-manager boundary with `deleteTarget.projectDirectory` only after the server delete succeeds, before completing navigation/dialog cleanup. - -- [x] **Step 5: Run the focused pagination test** - -Run: `bun test src/renderer/services/connection-manager.test.ts` - -Expected: PASS. - -### Task 3: Verify the combined Desktop changes - -**Files:** -- Verify only; no additional production files expected. - -**Interfaces:** -- Consumes: Tasks 1 and 2. -- Produces: focused regression evidence and repository hygiene evidence. - -- [x] **Step 1: Run both focused suites together** - -Run: `bun test src/renderer/components/chat/mention-popover.test.ts src/renderer/services/connection-manager.test.ts` - -Expected: all tests pass. - -- [x] **Step 2: Run Desktop type checking and production build** - -Run: `bun run check-types` - -Run: `bun run build` - -Expected: no new errors in touched files; record any unrelated dirty-tree failures exactly. - -- [x] **Step 3: Run diff hygiene** - -Run: `git diff --check` - -Expected: exit 0. - -## Verification Record - -- Focused suites: 7 tests passed across `mention-popover.test.ts` and `connection-manager.test.ts`. -- Production build: passed for main, preload, and renderer bundles. -- Type checking: no errors in task files; the dirty worktree still has three unrelated errors in `packages/ui/src/components/ai-elements/message.tsx`, `src/renderer/components/chat/process-timeline-view.tsx`, and `src/renderer/components/session-view.tsx`. -- Lint: unavailable because the configured `biome` executable is not installed (`/bin/bash: biome: command not found`). -- `git diff --check`: passed. diff --git a/docs/superpowers/plans/2026-07-12-desktop-request-user-input-and-plan-card.md b/docs/superpowers/plans/2026-07-12-desktop-request-user-input-and-plan-card.md deleted file mode 100644 index 507c6f60..00000000 --- a/docs/superpowers/plans/2026-07-12-desktop-request-user-input-and-plan-card.md +++ /dev/null @@ -1,162 +0,0 @@ -# Desktop Request User Input and Plan Card Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make real `request_user_input` server events interactive in Desktop and make the composer todo card opaque with state-correct chevrons. - -**Architecture:** Normalize the server's tagged `ServerEvent` wire shape at the Desktop SDK boundary and continue emitting the existing `question.asked` UI event, preserving the renderer's question atoms and response API. Keep the visual changes local to the existing `ChatQuestionFlow` and `SessionTaskList` components so protocol behavior and presentation remain independently testable. - -**Tech Stack:** TypeScript, React 19, Bun test, Tailwind CSS, Lucide icons, Jotai. - -## Global Constraints - -- Preserve all pre-existing uncommitted work and do not stage or commit without an explicit request. -- Keep renderer inline icons at `size-3.5` with `stroke-[1.5]` unless an established local pattern requires otherwise. -- Do not modify generated protocol types by hand. - ---- - -### Task 1: Normalize real request-user-input events - -**Files:** -- Modify: `apps/desktop/packages/devo-ai-sdk/src/v2/acp-client-support.ts` -- Modify: `apps/desktop/packages/devo-ai-sdk/src/v2/client.ts` -- Test: `apps/desktop/packages/devo-ai-sdk/src/v2/client.test.ts` - -**Interfaces:** -- Consumes: `_meta["devo/originalEvent"]` values serialized as `{ kind: "request_user_input", request, questions }` and legacy `{ RequestUserInput: payload }` values. -- Produces: the existing `question.asked` event and `_devo/request_user_input/respond` request path. - -- [x] **Step 1: Change the SDK test fixture to the actual tagged wire shape** - -```ts -"devo/originalEvent": { - kind: "request_user_input", - request: { - request_id: "rq1", - session_id: "s1", - turn_id: "t1", - item_id: null, - }, - questions: [ - { - id: "scope", - header: "Scope", - question: "Which scope?", - isOther: true, - isSecret: false, - options: [{ label: "Repo", description: "Current repository" }], - }, - ], -} -``` - -- [x] **Step 2: Run the focused test and verify it fails before the fix** - -Run: `bun test packages/devo-ai-sdk/src/v2/client.test.ts --test-name-pattern "maps original request_user_input"` - -Expected: FAIL because no `question.asked` event is emitted for the tagged event. - -Actual: the boundary regression test failed before implementation because `requestUserInputFromOriginalEvent` did not exist. - -- [x] **Step 3: Add one boundary parser and use it from the client** - -```ts -export function requestUserInputFromOriginalEvent(original: unknown): Record | undefined { - if (!original || typeof original !== "object") return undefined - const event = original as Record - if (event.kind === "request_user_input") return event - const legacy = event.RequestUserInput - return legacy && typeof legacy === "object" ? legacy as Record : undefined -} -``` - -Replace the legacy-only branch in `handleOriginalEvent` with a call to this parser and pass its result to `handleRequestUserInput`. - -- [x] **Step 4: Run the focused SDK test and verify it passes** - -Run: `bun test packages/devo-ai-sdk/src/v2/client.test.ts --test-name-pattern "maps original request_user_input"` - -Expected: PASS, including the full `question.asked` payload and response request equality. - -### Task 2: Polish the Desktop question card - -**Files:** -- Modify: `apps/desktop/src/renderer/components/chat/chat-question.tsx` -- Verify: `apps/desktop/src/renderer/components/chat/chat-view.tsx` - -**Interfaces:** -- Consumes: `QuestionRequest[]` from the existing Jotai question queue. -- Produces: a focused, keyboard-accessible, opaque card with option selection, custom input, progress, skip, back, and submit controls. - -- [x] **Step 1: Refine the card hierarchy without changing response semantics** - -Use an opaque `bg-card`, subtle shadow/ring treatment, a compact tinted icon container, a visible header label, and distinct option selected states. Keep the custom input and footer controls keyboard accessible. - -- [x] **Step 2: Verify protocol fields map to the UI contract** - -Confirm `isOther` controls custom answer visibility and `isSecret` selects a password input, while the current protocol remains single-select. - -Actual: preset and custom answers are mutually exclusive, options expose radio semantics, and the custom input has an explicit accessible label. - -- [x] **Step 3: Run Desktop type checking** - -Run: `bun run check-types` - -Expected: PASS with no TypeScript errors. - -Actual: the command ran but remains blocked by four pre-existing errors in `message.tsx`, `process-timeline-view.tsx`, `session-view.tsx`, and `use-file-search.ts`; none point to this task's parser or card components. - -### Task 3: Fix the composer todo card surface and arrow semantics - -**Files:** -- Modify: `apps/desktop/src/renderer/components/chat/session-task-list.tsx` - -**Interfaces:** -- Consumes: the existing `isExpanded` state and current session todos. -- Produces: an opaque todo card whose arrow points up when collapsed and down when expanded. - -- [x] **Step 1: Make the visible card opaque** - -Replace the translucent `bg-muted/10` surface with `bg-card` and retain a subtle border and hover state. - -- [x] **Step 2: Correct the arrow direction and accessible state** - -Use `ChevronDownIcon` when expanded and `ChevronUpIcon` when collapsed, add `aria-expanded={isExpanded}`, and keep the icon at `size-3.5 stroke-[1.5]`. - -- [x] **Step 3: Run formatting and focused checks** - -Run: `bunx biome check src/renderer/components/chat/chat-question.tsx src/renderer/components/chat/session-task-list.tsx packages/devo-ai-sdk/src/v2/acp-client-support.ts packages/devo-ai-sdk/src/v2/client.test.ts` - -Expected: PASS. - -Actual: focused source inspection and `git diff --check` pass. The repository's `bun run lint` command cannot start because the configured `biome` executable is not installed; an ad-hoc Biome binary does not resolve the repository's inherited monorepo configuration and reports existing whole-file diagnostics. - -### Task 4: End-to-end verification - -**Files:** -- Verify only: all files above. - -**Interfaces:** -- Consumes: completed Tasks 1-3. -- Produces: evidence that the SDK event path, renderer types, and production Desktop build all remain healthy. - -- [x] **Step 1: Run the SDK test file** - -Run: `bun test packages/devo-ai-sdk/src/v2/client.test.ts` - -Expected: all tests pass. - -- [x] **Step 2: Run Desktop type checking and build** - -Run: `bun run check-types && bun run build` - -Expected: both commands pass. - -Actual: the production Desktop build passes; type checking has the four pre-existing errors recorded in Task 2. - -- [x] **Step 3: Check patch hygiene** - -Run: `git diff --check` - -Expected: no whitespace errors. diff --git a/docs/superpowers/plans/2026-07-12-fast-initialize.md b/docs/superpowers/plans/2026-07-12-fast-initialize.md deleted file mode 100644 index f2ac4c24..00000000 --- a/docs/superpowers/plans/2026-07-12-fast-initialize.md +++ /dev/null @@ -1,66 +0,0 @@ -# Fast Initialize Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make ACP `initialize` complete quickly by preventing repeated synchronous construction of equivalent provider HTTP clients during server bootstrap. - -**Architecture:** Add a process-wide cache in `devo-provider` that owns one request client and one streaming client per proxy configuration. Provider adapters continue to own their headers, credentials, and endpoint configuration, while cloning the cached `reqwest::Client` handles so they share connection pools and avoid repeated native TLS initialization. - -**Tech Stack:** Rust, reqwest, anyhow, Cargo tests. - -## Global Constraints - -- Preserve existing provider and proxy behavior, including custom per-provider headers. -- Do not change ACP timeout values; remove the work that causes the timeout. -- Do not modify existing user changes outside the provider HTTP module and this plan. - ---- - -### Task 1: Cache equivalent provider HTTP clients - -**Files:** -- Modify: `crates/provider/src/http.rs` -- Test: `crates/provider/src/http.rs` - -**Interfaces:** -- Consumes: `ProviderHttpOptions::network_proxy`, `NetworkProxyConfig`, and the existing request/streaming client builders. -- Produces: unchanged `ProviderHttpOptions::build_request_client() -> Result` and `build_streaming_client() -> Result` behavior backed by shared cached clients. - -- [x] **Step 1: Write a failing unit test** - - Add a cache-level test that requests the same client kind twice for one proxy configuration, increments an `AtomicUsize` in the builder closure, and asserts the builder ran once. - -- [x] **Step 2: Run the test to verify it fails** - - Run `cargo test -p devo-provider http::tests::http_client_cache_reuses_equivalent_clients -- --exact` and confirm the missing cache implementation fails to compile. - -- [x] **Step 3: Implement the minimal cache** - - Add an `HttpClientCache` guarded by `OnceLock>`. Store request and streaming clients separately as `(NetworkProxyConfig, Client)` entries, return cheap `Client` clones on a hit, and build exactly once on a miss. - -- [x] **Step 4: Run focused provider tests** - - Run the new unit test and `cargo test -p devo-provider --test provider_http`; both must pass, preserving headers and explicit proxy routing. - -### Task 2: Verify startup behavior - -**Files:** -- No additional source changes expected. - -**Interfaces:** -- Consumes: the current multi-provider `~/.devo/config.toml` through an isolated temporary `DEVO_HOME`. -- Produces: measured config-load-to-database-open latency below the 10-second ACP response timeout, with `initialize` accepted successfully. - -- [x] **Step 1: Build the current binary** - - Run `cargo build -p devo-cli` and allow Rust compilation to finish normally. - -- [x] **Step 2: Benchmark isolated startup** - - Copy the current config, auth, and model catalog into a temporary home, send one ACP `initialize` request to `target/debug/devo server --transport stdio`, and compare timestamps between `loaded server config`, `opening database`, and `accepted ACP initialize request`. - - Measured result: config-load-to-database-open improved from approximately 12.8 seconds to 435 milliseconds, and ACP `initialize` was accepted at approximately 449 milliseconds. - -- [x] **Step 3: Run final checks** - - Run `cargo test -p devo-provider`, `cargo test -p devo-server provider_config`, and `git diff --check`. Confirm no unrelated dirty-tree files changed. From 1fd033b25195433aa0772ace7c1db79d49a03f02 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Tue, 21 Jul 2026 00:26:03 +0800 Subject: [PATCH 3/8] feat: harden sandbox approvals and permissions Gate silent unsandbox retries behind prior approval and deny-read profiles, add the linux-sandbox helper path, tighten shell metacharacter checks, and fix TUI approval/exec display. --- Cargo.lock | 1172 +++++- Cargo.toml | 10 + README.ja.md | 2 +- README.md | 2 +- README.ru.md | 2 +- README.zh-Hans.md | 2 +- README.zh-Hant.md | 2 +- apps/desktop/src/main/tray-menu.test.ts | 8 +- apps/desktop/src/main/tray-menu.ts | 6 +- apps/desktop/src/main/tray.ts | 4 +- .../renderer/atoms/derived/session-metrics.ts | 2 +- .../components/chat/chat-tool-call.test.ts | 94 +- .../components/chat/chat-tool-call.tsx | 357 +- .../components/chat/chat-turn.test.ts | 2 +- .../renderer/components/chat/chat-turn.tsx | 11 +- .../components/chat/process-timeline-view.tsx | 22 +- .../components/chat/process-timeline.ts | 2 +- .../components/chat/sub-agent-card.tsx | 36 +- .../components/chat/tool-card.test.tsx | 60 - .../renderer/components/chat/tool-card.tsx | 200 -- .../components/chat/tool-category.test.ts | 40 + .../renderer/components/chat/tool-category.ts | 43 + .../components/chat/transcript-disclosure.tsx | 29 +- .../src/renderer/lib/session-metrics.ts | 2 +- .../docs/configuration/other-providers.mdx | 6 +- .../docs/configuration/other-providers.zh.mdx | 6 +- .../content/docs/using-devo/permissions.mdx | 20 +- .../docs/using-devo/permissions.zh.mdx | 14 +- crates/arg0/Cargo.toml | 1 + crates/arg0/src/lib.rs | 74 +- crates/cli/Cargo.toml | 1 + crates/cli/src/agent_command.rs | 102 +- crates/cli/src/main.rs | 13 +- crates/cli/src/prompt_command.rs | 23 +- crates/client/README.md | 3 +- crates/client/src/acp_permissions.rs | 397 +- crates/client/src/client_core.rs | 8 + crates/client/src/stdio.rs | 7 + crates/client/src/websocket.rs | 9 + crates/config/README.md | 3 +- crates/config/src/app.rs | 4 + crates/config/src/tests.rs | 59 +- crates/core/Cargo.toml | 3 + crates/core/models.json | 96 +- crates/core/src/durable_record.rs | 20 + crates/core/src/mcp.rs | 2 +- crates/core/src/model_catalog.rs | 14 +- crates/core/src/query.rs | 13 +- crates/core/src/session.rs | 5 + .../core/src/tools/client_terminal_shell.rs | 1 + crates/core/src/tools/exec_policy_amend.rs | 114 + crates/core/src/tools/exec_policy_loader.rs | 135 + crates/core/src/tools/handlers/agent.rs | 1 + crates/core/src/tools/handlers/apply_patch.rs | 1 + crates/core/src/tools/handlers/bash.rs | 1 + crates/core/src/tools/handlers/code_search.rs | 1 + crates/core/src/tools/handlers/edit.rs | 1 + .../core/src/tools/handlers/exec_command.rs | 4 +- crates/core/src/tools/handlers/file_write.rs | 1 + crates/core/src/tools/handlers/goal_update.rs | 1 + crates/core/src/tools/handlers/read.rs | 1 + .../core/src/tools/handlers/shell_command.rs | 3 +- crates/core/src/tools/handlers/tool_search.rs | 2 + crates/core/src/tools/mod.rs | 6 + crates/core/src/tools/registry.rs | 1 + crates/core/src/tools/router.rs | 884 ++++- crates/core/src/tools/shell_exec.rs | 305 +- crates/core/src/tools/unified_exec/process.rs | 106 +- crates/core/tests/pty_sandbox_e2e.rs | 103 + crates/execpolicy/BUILD.bazel | 6 + crates/execpolicy/Cargo.toml | 33 + crates/execpolicy/README.md | 97 + .../execpolicy/examples/example.codexpolicy | 78 + crates/execpolicy/src/amend.rs | 339 ++ crates/execpolicy/src/decision.rs | 27 + crates/execpolicy/src/error.rs | 101 + crates/execpolicy/src/execpolicycheck.rs | 95 + crates/execpolicy/src/executable_name.rs | 29 + crates/execpolicy/src/lib.rs | 30 + crates/execpolicy/src/main.rs | 18 + crates/execpolicy/src/parser.rs | 473 +++ crates/execpolicy/src/policy.rs | 375 ++ crates/execpolicy/src/rule.rs | 306 ++ crates/execpolicy/tests/basic.rs | 963 +++++ crates/linux-sandbox/Cargo.toml | 25 + crates/linux-sandbox/src/lib.rs | 178 + crates/linux-sandbox/src/main.rs | 3 + crates/protocol/Cargo.toml | 1 + crates/protocol/src/acp.rs | 34 +- crates/protocol/src/acp_event_to_update.rs | 26 +- crates/protocol/src/acp_session_update.rs | 11 +- crates/protocol/src/acp_ts.rs | 8 + crates/protocol/src/approval.rs | 1 + crates/protocol/src/event.rs | 5 + crates/protocol/src/lib.rs | 2 + crates/protocol/src/permissions.rs | 34 +- crates/protocol/src/protocol.rs | 15 + crates/protocol/src/sandbox.rs | 21 + crates/protocol/src/slash_command.rs | 4 +- crates/protocol/src/user_input.rs | 2 +- crates/rmcp-client/LICENSE | 2 +- crates/rmcp-client/src/auth_status.rs | 2 +- .../rmcp-client/src/bin/test_stdio_server.rs | 26 +- .../src/bin/test_streamable_http_server.rs | 10 +- crates/rmcp-client/src/oauth.rs | 2 +- crates/rmcp-client/src/perform_oauth_login.rs | 2 +- crates/rmcp-client/src/utils.rs | 2 +- .../tests/process_group_cleanup.rs | 4 +- crates/rmcp-client/tests/resources.rs | 14 +- .../tests/streamable_http_test_support.rs | 4 +- crates/safety/Cargo.toml | 1 + crates/safety/src/lib.rs | 113 +- crates/safety/src/permission/mod.rs | 1 + crates/safety/src/permission/shell_access.rs | 156 + crates/safety/src/sandbox.rs | 277 -- crates/sandbox-network-proxy/Cargo.toml | 25 + crates/sandbox-network-proxy/src/lib.rs | 348 ++ crates/sandbox/Cargo.toml | 3 + crates/sandbox/src/bwrap.rs | 920 +++-- crates/sandbox/src/bwrap_placeholder.rs | 333 ++ crates/sandbox/src/child_net.rs | 445 --- crates/sandbox/src/denial.rs | 256 ++ crates/sandbox/src/deny/mod.rs | 30 +- crates/sandbox/src/lib.rs | 328 +- crates/sandbox/src/linux_helper.rs | 147 + crates/sandbox/src/logging.rs | 4 +- crates/sandbox/src/managed_network.rs | 206 ++ crates/sandbox/src/profiles.rs | 103 +- crates/sandbox/src/seatbelt.rs | 753 ++++ crates/sandbox/src/types.rs | 74 +- crates/sandbox/src/wrap.rs | 933 +++++ crates/sandbox/tests/deny_paths_e2e.rs | 4 +- crates/sandbox/tests/integration_test.rs | 21 +- crates/sandbox/tests/wrap_e2e.rs | 250 ++ crates/server/Cargo.toml | 4 + crates/server/src/approval_reviewer.rs | 110 +- crates/server/src/exec_policy_store.rs | 62 + crates/server/src/execution.rs | 33 +- crates/server/src/lib.rs | 2 + crates/server/src/runtime.rs | 51 +- crates/server/src/runtime/approval.rs | 1188 +++++- crates/server/src/runtime/command_exec.rs | 42 +- crates/server/src/runtime/connection.rs | 5 + .../src/runtime/handlers/acp/history.rs | 4 +- .../runtime/handlers/acp_config_options.rs | 62 +- crates/server/src/runtime/handlers/session.rs | 56 + .../src/runtime/session_actor/commands.rs | 4 + .../src/runtime/session_actor/handle.rs | 21 + .../server/src/runtime/session_actor/loop_.rs | 404 ++- .../src/runtime/session_actor/snapshots.rs | 1 + .../server/src/runtime/session_interactive.rs | 6 + crates/server/src/runtime/turn_exec/query.rs | 1 + crates/server/src/runtime/turn_exec/shell.rs | 1 + crates/server/src/sandbox_profile.rs | 123 + crates/server/src/session.rs | 3 +- crates/server/src/session_context.rs | 52 +- .../server/tests/acp_session_contract_e2e.rs | 5 +- crates/server/tests/cli_log_failures.rs | 4 +- crates/server/tests/protocol_contract.rs | 2 + .../server/tests/sandbox_approval_parity.rs | 82 + .../server/tests/session_sandbox_profile.rs | 393 ++ crates/server/tests/unified_exec_tool_e2e.rs | 8 +- crates/tools/src/contracts.rs | 4 + crates/tui/Cargo.toml | 1 + crates/tui/src/app.rs | 2 + crates/tui/src/app_command.rs | 12 +- .../tui/src/bottom_pane/approval_overlay.rs | 329 +- .../tui/src/bottom_pane/status_line_setup.rs | 11 +- crates/tui/src/bottom_pane/title_setup.rs | 6 +- crates/tui/src/chatwidget.rs | 7 + crates/tui/src/chatwidget/configuration.rs | 19 + .../tui/src/chatwidget/permission_presets.rs | 7 +- crates/tui/src/chatwidget/sandbox_profiles.rs | 161 + crates/tui/src/chatwidget/session_header.rs | 2 + crates/tui/src/chatwidget/slash_commands.rs | 13 + crates/tui/src/chatwidget/status_surfaces.rs | 10 +- crates/tui/src/chatwidget/worker_events.rs | 32 +- .../tui/src/chatwidget_tail_follow_tests.rs | 1 + crates/tui/src/chatwidget_tests.rs | 45 +- crates/tui/src/clipboard_paste.rs | 2 +- crates/tui/src/diff_render.rs | 8 +- crates/tui/src/events.rs | 4 + crates/tui/src/exec_cell/render.rs | 52 +- crates/tui/src/history_cell.rs | 8 +- crates/tui/src/inline_onboarding_tests.rs | 1 + crates/tui/src/interactive.rs | 16 + crates/tui/src/lib.rs | 1 + crates/tui/src/mcp_command_tests.rs | 1 + crates/tui/src/model_display_tests.rs | 3 + crates/tui/src/onboarding.rs | 64 +- .../tui/src/public_widgets/composer_input.rs | 2 +- crates/tui/src/shell_output.rs | 88 + crates/tui/src/tool_io_cell.rs | 3 + crates/tui/src/tool_rendering_e2e_tests.rs | 1 + crates/tui/src/tui.rs | 12 +- crates/tui/src/worker.rs | 57 +- crates/tui/src/worker/acp_events.rs | 9 +- .../tui/src/worker_queue_compaction_tests.rs | 1 + crates/utils/git/src/git_op/apply.rs | 6 +- crates/utils/git/src/git_op/ghost_commits.rs | 20 +- crates/utils/process/Cargo.toml | 1 + crates/utils/process/src/lib.rs | 1 + crates/utils/process/src/pty/mod.rs | 6 + crates/utils/process/src/pty/pipe.rs | 130 +- crates/utils/process/src/pty/tests.rs | 8 +- crates/utils/process/src/pty/win/mod.rs | 6 +- crates/utils/process/src/sandbox.rs | 39 + .../src/shell_command/parse_command.rs | 26 +- crates/windows-sandbox/BUILD.bazel | 9 + crates/windows-sandbox/Cargo.toml | 103 + crates/windows-sandbox/README.md | 47 + .../devo-windows-sandbox-setup.manifest | 10 + crates/windows-sandbox/sandbox_smoketests.py | 628 ++++ crates/windows-sandbox/src/acl.rs | 735 ++++ crates/windows-sandbox/src/allow.rs | 380 ++ crates/windows-sandbox/src/audit.rs | 350 ++ .../src/bin/command_runner/main.rs | 12 + .../src/bin/command_runner/win.rs | 693 ++++ .../bin/command_runner/win/cwd_junction.rs | 140 + .../src/bin/setup_main/main.rs | 12 + .../windows-sandbox/src/bin/setup_main/win.rs | 1253 +++++++ .../src/bin/setup_main/win/firewall.rs | 605 ++++ .../src/bin/setup_main/win/read_acl_mutex.rs | 61 + .../src/bin/setup_main/win/sandbox_users.rs | 585 +++ .../bin/setup_main/win/setup_runtime_bin.rs | 119 + .../setup_main/win/setup_runtime_bin_tests.rs | 30 + crates/windows-sandbox/src/cap.rs | 203 ++ crates/windows-sandbox/src/capture_stub.rs | 40 + crates/windows-sandbox/src/conpty/mod.rs | 160 + crates/windows-sandbox/src/deny_read_acl.rs | 119 + .../windows-sandbox/src/deny_read_resolver.rs | 381 ++ crates/windows-sandbox/src/deny_read_state.rs | 87 + crates/windows-sandbox/src/desktop.rs | 196 + crates/windows-sandbox/src/dpapi.rs | 85 + .../src/elevated/ipc_framed.rs | 277 ++ crates/windows-sandbox/src/elevated/mod.rs | 3 + .../src/elevated/runner_client.rs | 555 +++ .../src/elevated/runner_pipe.rs | 135 + crates/windows-sandbox/src/elevated_impl.rs | 316 ++ crates/windows-sandbox/src/env.rs | 177 + .../src/helper_materialization.rs | 566 +++ crates/windows-sandbox/src/hide_users.rs | 158 + crates/windows-sandbox/src/identity.rs | 374 ++ crates/windows-sandbox/src/launch.rs | 54 + crates/windows-sandbox/src/lib.rs | 450 +++ crates/windows-sandbox/src/logging.rs | 159 + crates/windows-sandbox/src/otel_stub.rs | 65 + .../windows-sandbox/src/path_normalization.rs | 31 + crates/windows-sandbox/src/path_util.rs | 23 + .../windows-sandbox/src/proc_thread_attr.rs | 100 + crates/windows-sandbox/src/process.rs | 373 ++ .../src/protocol/config_types.rs | 13 + .../src/protocol/legacy_protocol.rs | 218 ++ crates/windows-sandbox/src/protocol/mod.rs | 20 + crates/windows-sandbox/src/protocol/models.rs | 425 +++ .../src/protocol/permissions.rs | 3183 +++++++++++++++++ crates/windows-sandbox/src/pty/conpty.rs | 58 + crates/windows-sandbox/src/pty/mod.rs | 22 + crates/windows-sandbox/src/pty/process.rs | 275 ++ .../windows-sandbox/src/pty/procthreadattr.rs | 91 + crates/windows-sandbox/src/pty/psuedocon.rs | 133 + .../windows-sandbox/src/pty/windows_input.rs | 24 + crates/windows-sandbox/src/request_adapter.rs | 80 + .../src/resolved_permissions.rs | 478 +++ crates/windows-sandbox/src/sandbox_utils.rs | 116 + crates/windows-sandbox/src/setup.rs | 2155 +++++++++++ crates/windows-sandbox/src/setup_error.rs | 289 ++ crates/windows-sandbox/src/spawn_prep.rs | 716 ++++ .../src/ssh_config_dependencies.rs | 249 ++ crates/windows-sandbox/src/stdio_bridge.rs | 126 + .../windows-sandbox/src/stdio_bridge_tests.rs | 63 + crates/windows-sandbox/src/string_util.rs | 43 + crates/windows-sandbox/src/token.rs | 483 +++ .../src/unified_exec/backends/elevated.rs | 240 ++ .../unified_exec/backends/elevated_tests.rs | 192 + .../src/unified_exec/backends/legacy.rs | 445 +++ .../src/unified_exec/backends/mod.rs | 3 + .../unified_exec/backends/windows_common.rs | 176 + .../windows-sandbox/src/unified_exec/mod.rs | 174 + .../windows-sandbox/src/unified_exec/tests.rs | 778 ++++ crates/windows-sandbox/src/wfp.rs | 422 +++ .../windows-sandbox/src/wfp/filter_specs.rs | 124 + crates/windows-sandbox/src/wfp_setup.rs | 176 + crates/windows-sandbox/src/windows_impl.rs | 440 +++ crates/windows-sandbox/src/winutil.rs | 241 ++ crates/windows-sandbox/src/workspace_acl.rs | 30 + crates/windows-sandbox/src/wrapper.rs | 329 ++ crates/windows-sandbox/src/wrapper_tests.rs | 110 + .../goal/L2-DES-GOAL-001-ralph-loop-goals.md | 4 +- specs/traceability/verification.md | 6 +- 290 files changed, 39326 insertions(+), 2474 deletions(-) delete mode 100644 apps/desktop/src/renderer/components/chat/tool-card.test.tsx delete mode 100644 apps/desktop/src/renderer/components/chat/tool-card.tsx create mode 100644 apps/desktop/src/renderer/components/chat/tool-category.test.ts create mode 100644 apps/desktop/src/renderer/components/chat/tool-category.ts create mode 100644 crates/core/src/tools/exec_policy_amend.rs create mode 100644 crates/core/src/tools/exec_policy_loader.rs create mode 100644 crates/core/tests/pty_sandbox_e2e.rs create mode 100644 crates/execpolicy/BUILD.bazel create mode 100644 crates/execpolicy/Cargo.toml create mode 100644 crates/execpolicy/README.md create mode 100644 crates/execpolicy/examples/example.codexpolicy create mode 100644 crates/execpolicy/src/amend.rs create mode 100644 crates/execpolicy/src/decision.rs create mode 100644 crates/execpolicy/src/error.rs create mode 100644 crates/execpolicy/src/execpolicycheck.rs create mode 100644 crates/execpolicy/src/executable_name.rs create mode 100644 crates/execpolicy/src/lib.rs create mode 100644 crates/execpolicy/src/main.rs create mode 100644 crates/execpolicy/src/parser.rs create mode 100644 crates/execpolicy/src/policy.rs create mode 100644 crates/execpolicy/src/rule.rs create mode 100644 crates/execpolicy/tests/basic.rs create mode 100644 crates/linux-sandbox/Cargo.toml create mode 100644 crates/linux-sandbox/src/lib.rs create mode 100644 crates/linux-sandbox/src/main.rs create mode 100644 crates/protocol/src/sandbox.rs delete mode 100644 crates/safety/src/sandbox.rs create mode 100644 crates/sandbox-network-proxy/Cargo.toml create mode 100644 crates/sandbox-network-proxy/src/lib.rs create mode 100644 crates/sandbox/src/bwrap_placeholder.rs delete mode 100644 crates/sandbox/src/child_net.rs create mode 100644 crates/sandbox/src/denial.rs create mode 100644 crates/sandbox/src/linux_helper.rs create mode 100644 crates/sandbox/src/managed_network.rs create mode 100644 crates/sandbox/src/seatbelt.rs create mode 100644 crates/sandbox/src/wrap.rs create mode 100644 crates/sandbox/tests/wrap_e2e.rs create mode 100644 crates/server/src/exec_policy_store.rs create mode 100644 crates/server/src/sandbox_profile.rs create mode 100644 crates/server/tests/sandbox_approval_parity.rs create mode 100644 crates/server/tests/session_sandbox_profile.rs create mode 100644 crates/tui/src/chatwidget/sandbox_profiles.rs create mode 100644 crates/tui/src/shell_output.rs create mode 100644 crates/utils/process/src/sandbox.rs create mode 100644 crates/windows-sandbox/BUILD.bazel create mode 100644 crates/windows-sandbox/Cargo.toml create mode 100644 crates/windows-sandbox/README.md create mode 100644 crates/windows-sandbox/devo-windows-sandbox-setup.manifest create mode 100644 crates/windows-sandbox/sandbox_smoketests.py create mode 100644 crates/windows-sandbox/src/acl.rs create mode 100644 crates/windows-sandbox/src/allow.rs create mode 100644 crates/windows-sandbox/src/audit.rs create mode 100644 crates/windows-sandbox/src/bin/command_runner/main.rs create mode 100644 crates/windows-sandbox/src/bin/command_runner/win.rs create mode 100644 crates/windows-sandbox/src/bin/command_runner/win/cwd_junction.rs create mode 100644 crates/windows-sandbox/src/bin/setup_main/main.rs create mode 100644 crates/windows-sandbox/src/bin/setup_main/win.rs create mode 100644 crates/windows-sandbox/src/bin/setup_main/win/firewall.rs create mode 100644 crates/windows-sandbox/src/bin/setup_main/win/read_acl_mutex.rs create mode 100644 crates/windows-sandbox/src/bin/setup_main/win/sandbox_users.rs create mode 100644 crates/windows-sandbox/src/bin/setup_main/win/setup_runtime_bin.rs create mode 100644 crates/windows-sandbox/src/bin/setup_main/win/setup_runtime_bin_tests.rs create mode 100644 crates/windows-sandbox/src/cap.rs create mode 100644 crates/windows-sandbox/src/capture_stub.rs create mode 100644 crates/windows-sandbox/src/conpty/mod.rs create mode 100644 crates/windows-sandbox/src/deny_read_acl.rs create mode 100644 crates/windows-sandbox/src/deny_read_resolver.rs create mode 100644 crates/windows-sandbox/src/deny_read_state.rs create mode 100644 crates/windows-sandbox/src/desktop.rs create mode 100644 crates/windows-sandbox/src/dpapi.rs create mode 100644 crates/windows-sandbox/src/elevated/ipc_framed.rs create mode 100644 crates/windows-sandbox/src/elevated/mod.rs create mode 100644 crates/windows-sandbox/src/elevated/runner_client.rs create mode 100644 crates/windows-sandbox/src/elevated/runner_pipe.rs create mode 100644 crates/windows-sandbox/src/elevated_impl.rs create mode 100644 crates/windows-sandbox/src/env.rs create mode 100644 crates/windows-sandbox/src/helper_materialization.rs create mode 100644 crates/windows-sandbox/src/hide_users.rs create mode 100644 crates/windows-sandbox/src/identity.rs create mode 100644 crates/windows-sandbox/src/launch.rs create mode 100644 crates/windows-sandbox/src/lib.rs create mode 100644 crates/windows-sandbox/src/logging.rs create mode 100644 crates/windows-sandbox/src/otel_stub.rs create mode 100644 crates/windows-sandbox/src/path_normalization.rs create mode 100644 crates/windows-sandbox/src/path_util.rs create mode 100644 crates/windows-sandbox/src/proc_thread_attr.rs create mode 100644 crates/windows-sandbox/src/process.rs create mode 100644 crates/windows-sandbox/src/protocol/config_types.rs create mode 100644 crates/windows-sandbox/src/protocol/legacy_protocol.rs create mode 100644 crates/windows-sandbox/src/protocol/mod.rs create mode 100644 crates/windows-sandbox/src/protocol/models.rs create mode 100644 crates/windows-sandbox/src/protocol/permissions.rs create mode 100644 crates/windows-sandbox/src/pty/conpty.rs create mode 100644 crates/windows-sandbox/src/pty/mod.rs create mode 100644 crates/windows-sandbox/src/pty/process.rs create mode 100644 crates/windows-sandbox/src/pty/procthreadattr.rs create mode 100644 crates/windows-sandbox/src/pty/psuedocon.rs create mode 100644 crates/windows-sandbox/src/pty/windows_input.rs create mode 100644 crates/windows-sandbox/src/request_adapter.rs create mode 100644 crates/windows-sandbox/src/resolved_permissions.rs create mode 100644 crates/windows-sandbox/src/sandbox_utils.rs create mode 100644 crates/windows-sandbox/src/setup.rs create mode 100644 crates/windows-sandbox/src/setup_error.rs create mode 100644 crates/windows-sandbox/src/spawn_prep.rs create mode 100644 crates/windows-sandbox/src/ssh_config_dependencies.rs create mode 100644 crates/windows-sandbox/src/stdio_bridge.rs create mode 100644 crates/windows-sandbox/src/stdio_bridge_tests.rs create mode 100644 crates/windows-sandbox/src/string_util.rs create mode 100644 crates/windows-sandbox/src/token.rs create mode 100644 crates/windows-sandbox/src/unified_exec/backends/elevated.rs create mode 100644 crates/windows-sandbox/src/unified_exec/backends/elevated_tests.rs create mode 100644 crates/windows-sandbox/src/unified_exec/backends/legacy.rs create mode 100644 crates/windows-sandbox/src/unified_exec/backends/mod.rs create mode 100644 crates/windows-sandbox/src/unified_exec/backends/windows_common.rs create mode 100644 crates/windows-sandbox/src/unified_exec/mod.rs create mode 100644 crates/windows-sandbox/src/unified_exec/tests.rs create mode 100644 crates/windows-sandbox/src/wfp.rs create mode 100644 crates/windows-sandbox/src/wfp/filter_specs.rs create mode 100644 crates/windows-sandbox/src/wfp_setup.rs create mode 100644 crates/windows-sandbox/src/windows_impl.rs create mode 100644 crates/windows-sandbox/src/winutil.rs create mode 100644 crates/windows-sandbox/src/workspace_acl.rs create mode 100644 crates/windows-sandbox/src/wrapper.rs create mode 100644 crates/windows-sandbox/src/wrapper_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 87b64e91..a43528a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,16 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "Inflector" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" +dependencies = [ + "lazy_static", + "regex", +] + [[package]] name = "adler2" version = "2.0.1" @@ -48,7 +58,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d176a10d4cb06e0262a738c3c5bf21ff0968db13a666e31cbca94a3d3d72e7c" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -58,7 +68,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c290bfa00c6b52339db66f8e9cf711d5f08530800529f7d619ff24d6cba253d0" dependencies = [ "anyhow", - "derive_more", + "derive_more 2.1.1", "schemars 1.2.1", "serde", "serde_json", @@ -90,6 +100,30 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocative" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8cf9afc79c83d514444b55df3935d317da54b1ce3b17a133c646889cc260de8" +dependencies = [ + "allocative_derive", + "bumpalo", + "ctor 1.0.9", + "hashbrown 0.16.1", + "num-bigint", +] + +[[package]] +name = "allocative_derive" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "614043c56c1173b800acb007b81fd0cbc0a0d7d717b71ba705fc2230d0760a23" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -122,6 +156,15 @@ dependencies = [ "rayon", ] +[[package]] +name = "annotate-snippets" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccaf7e9dfbb6ab22c82e473cd1a8a7bd313c19a5b7e40970f3d89ef5a5c9e81e" +dependencies = [ + "unicode-width 0.1.14", +] + [[package]] name = "ansi-to-tui" version = "7.0.0" @@ -349,7 +392,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -389,7 +432,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -406,7 +449,22 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", ] [[package]] @@ -520,6 +578,12 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + [[package]] name = "bincode" version = "1.3.3" @@ -529,6 +593,21 @@ dependencies = [ "serde", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -543,16 +622,17 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "blake3" -version = "1.8.5" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +checksum = "3888aaa89e4b2a40fca9848e400f6a658a5a3978de7be858e209cafa8be9a4a0" dependencies = [ "arrayref", "arrayvec", "cc", "cfg-if 1.0.4", "constant_time_eq", - "cpufeatures 0.3.0", + "digest 0.10.7", + "rayon-core", ] [[package]] @@ -631,7 +711,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.117", ] [[package]] @@ -675,6 +755,20 @@ name = "bytemuck" version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "byteorder" @@ -718,7 +812,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -847,7 +941,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.11.1", ] [[package]] @@ -859,7 +953,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -886,6 +980,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmp_any" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21" + [[package]] name = "cmpv2" version = "0.2.0" @@ -910,6 +1010,15 @@ dependencies = [ "x509-cert", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -1076,9 +1185,18 @@ dependencies = [ [[package]] name = "constant_time_eq" -version = "0.4.2" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] [[package]] name = "convert_case" @@ -1152,6 +1270,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.5.0" @@ -1161,6 +1294,12 @@ dependencies = [ "cfg-if 1.0.4", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crmf" version = "0.2.0" @@ -1288,6 +1427,16 @@ dependencies = [ "dtor", ] +[[package]] +name = "ctor" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a394189d59f9befacce833f337f7b1eca5e9a91221bcdd4d28e0114d96e597b3" +dependencies = [ + "link-section", + "linktime-proc-macro", +] + [[package]] name = "ctor-proc-macro" version = "0.0.7" @@ -1324,8 +1473,8 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", - "syn", + "strsim 0.11.1", + "syn 2.0.117", ] [[package]] @@ -1337,8 +1486,8 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", - "syn", + "strsim 0.11.1", + "syn 2.0.117", ] [[package]] @@ -1349,7 +1498,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1360,7 +1509,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1372,6 +1521,20 @@ dependencies = [ "serde", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if 1.0.4", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -1407,6 +1570,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "debugserver-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf6834a70ed14e8e4e41882df27190bea150f1f6ecf461f1033f8739cd8af4a" +dependencies = [ + "schemafy", + "serde", + "serde_json", +] + [[package]] name = "der" version = "0.7.10" @@ -1428,7 +1602,7 @@ checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1440,6 +1614,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -1458,7 +1643,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1468,7 +1653,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl 1.0.0", ] [[package]] @@ -1477,7 +1671,20 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ - "derive_more-impl", + "derive_more-impl 2.1.1", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "convert_case 0.6.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "unicode-xid", ] [[package]] @@ -1486,11 +1693,11 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case", + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.117", "unicode-xid", ] @@ -1508,6 +1715,7 @@ dependencies = [ "clap", "console-subscriber", "devo-core", + "devo-linux-sandbox", "devo-server", "devo-util-paths", "dotenvy", @@ -1537,6 +1745,7 @@ dependencies = [ "devo-tasks", "devo-tui", "devo-util-paths", + "devo-windows-sandbox", "futures", "pretty_assertions", "serde", @@ -1643,14 +1852,17 @@ dependencies = [ "chrono", "devo-code-search", "devo-config", + "devo-execpolicy", "devo-network-proxy", "devo-protocol", "devo-provider", "devo-safety", + "devo-sandbox", "devo-skills", "devo-tools", "devo-util-paths", "devo-util-process", + "devo-windows-sandbox", "diffy", "futures", "iana-time-zone", @@ -1676,6 +1888,23 @@ dependencies = [ "uuid", ] +[[package]] +name = "devo-execpolicy" +version = "0.1.30" +dependencies = [ + "anyhow", + "clap", + "devo-util-paths", + "multimap", + "pretty_assertions", + "serde", + "serde_json", + "shlex 1.3.0", + "starlark", + "tempfile", + "thiserror 2.0.18", +] + [[package]] name = "devo-file-search" version = "0.1.30" @@ -1700,6 +1929,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "devo-linux-sandbox" +version = "0.1.30" +dependencies = [ + "anyhow", + "clap", + "devo-sandbox", + "libc", + "serde", + "serde_json", +] + [[package]] name = "devo-mcp" version = "0.1.30" @@ -1742,6 +1983,7 @@ dependencies = [ "strum 0.27.2", "strum_macros 0.28.0", "thiserror 2.0.18", + "tracing", "ts-rs", "uuid", ] @@ -1816,6 +2058,7 @@ dependencies = [ "smol_str 0.3.2", "thiserror 2.0.18", "tokio", + "tracing", "tree-sitter", "url", ] @@ -1827,6 +2070,7 @@ dependencies = [ "anyhow", "chrono", "devo-util-paths", + "devo-windows-sandbox", "dirs", "dunce", "globset", @@ -1845,6 +2089,18 @@ dependencies = [ "url", ] +[[package]] +name = "devo-sandbox-network-proxy" +version = "0.1.30" +dependencies = [ + "anyhow", + "devo-sandbox", + "pretty_assertions", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "devo-server" version = "0.1.30" @@ -1857,11 +2113,14 @@ dependencies = [ "devo-client", "devo-code-search", "devo-core", + "devo-execpolicy", "devo-file-search", "devo-mcp", "devo-protocol", "devo-provider", "devo-safety", + "devo-sandbox", + "devo-sandbox-network-proxy", "devo-util-fuzzy", "devo-util-git", "devo-util-paths", @@ -1874,6 +2133,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "shlex 1.3.0", "smol_str 0.3.2", "tempfile", "thiserror 2.0.18", @@ -1944,11 +2204,12 @@ dependencies = [ "base64 0.22.1", "chrono", "crossterm", - "derive_more", + "derive_more 2.1.1", "devo-client", "devo-core", "devo-protocol", "devo-provider", + "devo-sandbox", "devo-server", "devo-util-fuzzy", "devo-util-git", @@ -1978,7 +2239,7 @@ dependencies = [ "supports-color", "syntect", "tempfile", - "textwrap", + "textwrap 0.16.2", "tokio", "tokio-stream", "tokio-util", @@ -2037,6 +2298,7 @@ name = "devo-util-process" version = "0.1.30" dependencies = [ "anyhow", + "devo-sandbox", "filedescriptor", "lazy_static", "libc", @@ -2068,6 +2330,37 @@ dependencies = [ "which", ] +[[package]] +name = "devo-windows-sandbox" +version = "0.1.30" +dependencies = [ + "anyhow", + "base64 0.22.1", + "chrono", + "devo-util-paths", + "dirs-next", + "dunce", + "filedescriptor", + "glob", + "globset", + "lazy_static", + "log", + "pretty_assertions", + "rand 0.8.6", + "serde", + "serde_json", + "shared_library", + "strum 0.27.2", + "strum_macros 0.28.0", + "tempfile", + "tokio", + "tracing", + "tracing-appender", + "winapi", + "windows 0.58.0", + "windows-sys 0.52.0", +] + [[package]] name = "diff" version = "0.1.13" @@ -2114,6 +2407,16 @@ dependencies = [ "dirs-sys", ] +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if 1.0.4", + "dirs-sys-next", +] + [[package]] name = "dirs-sys" version = "0.5.0" @@ -2122,10 +2425,21 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + [[package]] name = "dispatch2" version = "0.3.1" @@ -2136,6 +2450,16 @@ dependencies = [ "objc2", ] +[[package]] +name = "display_container" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a110a75c96bedec8e65823dea00a1d710288b7a369d95fd8a0f5127639466fa" +dependencies = [ + "either", + "indenter", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -2144,7 +2468,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2180,6 +2504,26 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dupe" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed2bc011db9c93fbc2b6cdb341a53737a55bafb46dbb74cf6764fc33a2fbf9c" +dependencies = [ + "dupe_derive", +] + +[[package]] +name = "dupe_derive" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e195b4945e88836d826124af44fdcb262ec01ef94d44f14f4fb5103f19892a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "dyn-clone" version = "1.0.20" @@ -2192,6 +2536,18 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -2213,6 +2569,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + [[package]] name = "enum-as-inner" version = "0.6.1" @@ -2222,7 +2584,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2243,7 +2605,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2275,6 +2637,26 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erased-serde" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c138974f9d5e7fe373eb04df7cae98833802ae4b11c24ac7039a21d5af4b26c" +dependencies = [ + "serde", +] + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + [[package]] name = "errno" version = "0.3.14" @@ -2344,6 +2726,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -2356,6 +2749,17 @@ version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if 1.0.4", + "rustix 1.1.4", + "windows-sys 0.59.0", +] + [[package]] name = "fdeflate" version = "0.3.7" @@ -2404,6 +2808,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluent-uri" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2538,7 +2951,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2683,9 +3096,15 @@ checksum = "53010ccb100b96a67bc32c0175f0ed1426b31b655d562898e57325f81c023ac0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "globset" version = "0.4.18" @@ -2729,12 +3148,27 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -2791,6 +3225,20 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0049b265b7f201ca9ab25475b22b47fe444060126a51abe00f77d986fc5cc52e" +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -2900,6 +3348,15 @@ dependencies = [ "serde", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.4.2" @@ -3049,7 +3506,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -3224,6 +3681,12 @@ dependencies = [ "quote", ] +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + [[package]] name = "indexmap" version = "1.9.3" @@ -3321,7 +3784,16 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", ] [[package]] @@ -3387,7 +3859,7 @@ checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3417,7 +3889,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.117", ] [[package]] @@ -3436,7 +3908,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3584,12 +4056,24 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "link-section" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e333fe507b738576d6da5bb3f1a7d7a1c80307ed9ef31624c057d844c19c93e9" + [[package]] name = "linked-hash-map" version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +[[package]] +name = "linktime-proc-macro" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7b0a3383c2a1002d11349c92c85a666a5fb679e96c79d782cf0dbe557fd6ee" + [[package]] name = "linux-keyutils" version = "0.2.5" @@ -3627,12 +4111,56 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "lock_free_hashtable" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebf3631712f5b790675292ff827af269f5d9f920c920b77dc41d0485e3719612" +dependencies = [ + "atomic", + "parking_lot", +] + [[package]] name = "log" version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +[[package]] +name = "logos" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff472f899b4ec2d99161c51f60ff7075eeb3097069a36050d8037a6325eb8154" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "192a3a2b90b0c05b27a0b2c43eecdb7c415e29243acc3f89cc8247a5b693045c" +dependencies = [ + "beef", + "fnv", + "lazy_static", + "proc-macro2", + "quote", + "regex-syntax", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "logos-derive" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "605d9697bcd5ef3a42d38efc51541aa3d6a4a25f7ab6d1ed0da5ac632a26b470" +dependencies = [ + "logos-codegen", +] + [[package]] name = "lru" version = "0.12.5" @@ -3648,6 +4176,19 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lsp-types" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" +dependencies = [ + "bitflags 1.3.2", + "fluent-uri", + "serde", + "serde_json", + "serde_repr", +] + [[package]] name = "lz4_flex" version = "0.13.1" @@ -3682,6 +4223,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + [[package]] name = "matchers" version = "0.2.0" @@ -3816,7 +4363,7 @@ checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3835,6 +4382,15 @@ dependencies = [ "pxfm", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +dependencies = [ + "serde", +] + [[package]] name = "ndarray" version = "0.16.1" @@ -3856,6 +4412,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + [[package]] name = "nix" version = "0.28.0" @@ -3944,7 +4509,7 @@ dependencies = [ "sha2 0.11.0", "sigstore-trust-root", "sigstore-verify", - "syn", + "syn 2.0.117", "thiserror 2.0.18", "tracing", "typify", @@ -4039,6 +4604,7 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", + "serde", ] [[package]] @@ -4297,12 +4863,59 @@ dependencies = [ ] [[package]] -name = "os_str_bytes" -version = "6.6.1" +name = "os_str_bytes" +version = "6.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" +dependencies = [ + "memchr", +] + +[[package]] +name = "pagable" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3658968938a4d1eaa1987e69dcd84b01fb067c5b3416dccc8d71373b6ded6821" +dependencies = [ + "allocative", + "anyhow", + "async-trait", + "blake3", + "bytemuck", + "dashmap", + "dupe", + "either", + "erased-serde 0.4.10", + "fancy-regex", + "indexmap 2.14.0", + "inventory", + "num-bigint", + "once_cell", + "pagable_derive", + "parking_lot", + "postcard", + "regex", + "sequence_trie", + "serde", + "serde_json", + "smallvec", + "sorted_vector_map", + "static_assertions", + "static_interner", + "strong_hash", + "take_mut", + "triomphe", +] + +[[package]] +name = "pagable_derive" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" +checksum = "838d17166587914f4e99353766c29160462b681511f08679545a0d07a0dc9415" dependencies = [ - "memchr", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -4405,7 +5018,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4507,6 +5120,20 @@ dependencies = [ "winreg", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "crc", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -4548,7 +5175,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -4603,7 +5230,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4664,6 +5291,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "quickcheck" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95c589f335db0f6aaa168a7cd27b1fc6920f5e1470c804f814d9cd6e62a0f70b" +dependencies = [ + "env_logger", + "log", + "rand 0.10.1", +] + [[package]] name = "quinn" version = "0.11.9" @@ -4741,6 +5379,16 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + [[package]] name = "rand" version = "0.8.6" @@ -4901,6 +5549,17 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -4929,7 +5588,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5154,7 +5813,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn", + "syn 2.0.117", ] [[package]] @@ -5304,6 +5963,28 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rustyline" +version = "14.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63" +dependencies = [ + "bitflags 2.13.0", + "cfg-if 1.0.4", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix 0.28.0", + "radix_trie", + "unicode-segmentation", + "unicode-width 0.1.14", + "utf8parse", + "windows-sys 0.52.0", +] + [[package]] name = "ryu" version = "1.0.23" @@ -5350,6 +6031,48 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemafy" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8aea5ba40287dae331f2c48b64dbc8138541f5e97ee8793caa7948c1f31d86d5" +dependencies = [ + "Inflector", + "schemafy_core", + "schemafy_lib", + "serde", + "serde_derive", + "serde_json", + "serde_repr", + "syn 1.0.109", +] + +[[package]] +name = "schemafy_core" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41781ae092f4fd52c9287efb74456aea0d3b90032d2ecad272bd14dbbcb0511b" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "schemafy_lib" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e953db32579999ca98c451d80801b6f6a7ecba6127196c5387ec0774c528befa" +dependencies = [ + "Inflector", + "proc-macro2", + "quote", + "schemafy_core", + "serde", + "serde_derive", + "serde_json", + "syn 1.0.109", +] + [[package]] name = "schemars" version = "0.8.22" @@ -5400,7 +6123,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn", + "syn 2.0.117", ] [[package]] @@ -5412,7 +6135,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn", + "syn 2.0.117", ] [[package]] @@ -5486,6 +6209,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sequence_trie" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ee22067b7ccd072eeb64454b9c6e1b33b61cd0d49e895fd48676a184580e0c3" + [[package]] name = "serde" version = "1.0.228" @@ -5513,7 +6242,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5524,7 +6253,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5571,7 +6300,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5592,7 +6321,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn", + "syn 2.0.117", ] [[package]] @@ -5636,7 +6365,7 @@ dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5685,7 +6414,7 @@ checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6054,6 +6783,25 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sorted_vector_map" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94bf565ee1681b4473aa5a9d71d807347c28021bd1d8947cb626b02f42a0141f" +dependencies = [ + "itertools 0.14.0", + "quickcheck", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + [[package]] name = "spki" version = "0.7.3" @@ -6095,12 +6843,118 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "starlark" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9062e866918dc4c9701c98ac99f7f4fa9e4b3b4edce306e147393bc75458c4fc" +dependencies = [ + "allocative", + "anyhow", + "blake3", + "bumpalo", + "cmp_any", + "dashmap", + "debugserver-types", + "derivative", + "derive_more 1.0.0", + "display_container", + "dupe", + "either", + "erased-serde 0.3.31", + "hashbrown 0.16.1", + "indexmap 2.14.0", + "inventory", + "itertools 0.14.0", + "maplit", + "memoffset", + "num-bigint", + "num-traits", + "once_cell", + "pagable", + "paste", + "ref-cast", + "regex", + "rustyline", + "serde", + "serde_json", + "starlark_derive", + "starlark_map", + "starlark_syntax", + "static_assertions", + "strong_hash", + "strsim 0.10.0", + "textwrap 0.11.0", + "thiserror 2.0.18", +] + +[[package]] +name = "starlark_derive" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "797e235eb70936bfa14fabf490bf7453e6f0caaf6b9c56fe4c9aff02aee7e66d" +dependencies = [ + "dupe", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "starlark_map" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234877898fd216af93b2f5798b08cbbdc1a2e8f16a622a258b1db23a61a1c4ba" +dependencies = [ + "allocative", + "dupe", + "equivalent", + "fxhash", + "hashbrown 0.16.1", + "pagable", + "serde", + "strong_hash", +] + +[[package]] +name = "starlark_syntax" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7492c571c531e68099c911cfd909d32659f1cc0910cf3adee9fce66e39d21f14" +dependencies = [ + "allocative", + "annotate-snippets", + "anyhow", + "derivative", + "derive_more 1.0.0", + "dupe", + "logos", + "lsp-types", + "memchr", + "num-bigint", + "num-traits", + "once_cell", + "pagable", + "starlark_map", + "thiserror 2.0.18", +] + [[package]] name = "static_assertions" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "static_interner" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a72d2480db611b8ee9287b4a2e0adc63c4d7fdd647d2a1a65d529fc234fd16" +dependencies = [ + "equivalent", + "lock_free_hashtable", +] + [[package]] name = "statrs" version = "0.18.0" @@ -6126,6 +6980,32 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" +[[package]] +name = "strong_hash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0831334aea34390b6b6ec7af0a27f9ee6324ad3a69463e6b240d83d6b7bce9c9" +dependencies = [ + "ref-cast", + "strong_hash_derive", +] + +[[package]] +name = "strong_hash_derive" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace6b48b7c4383a39bd3b966cca41bc999003aab9f690a2f355525c924296928" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "strsim" version = "0.11.1" @@ -6166,7 +7046,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.117", ] [[package]] @@ -6178,7 +7058,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6202,6 +7082,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -6230,7 +7121,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6303,6 +7194,12 @@ dependencies = [ "libc", ] +[[package]] +name = "take_mut" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" + [[package]] name = "tempfile" version = "3.27.0" @@ -6325,6 +7222,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width 0.1.14", +] + [[package]] name = "textwrap" version = "0.16.2" @@ -6362,7 +7268,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6373,7 +7279,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6484,7 +7390,7 @@ checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6547,7 +7453,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6802,7 +7708,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -7138,6 +8044,16 @@ dependencies = [ "petgraph", ] +[[package]] +name = "triomphe" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" +dependencies = [ + "serde", + "stable_deref_trait", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -7166,7 +8082,7 @@ checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "termcolor", ] @@ -7204,6 +8120,12 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" version = "1.20.1" @@ -7241,7 +8163,7 @@ dependencies = [ "semver", "serde", "serde_json", - "syn", + "syn 2.0.117", "thiserror 2.0.18", "unicode-ident", ] @@ -7259,7 +8181,7 @@ dependencies = [ "serde", "serde_json", "serde_tokenstream", - "syn", + "syn 2.0.117", "typify-impl", ] @@ -7551,7 +8473,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -7822,6 +8744,16 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.62.2" @@ -7829,7 +8761,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ "windows-collections", - "windows-core", + "windows-core 0.62.2", "windows-future", "windows-numerics", ] @@ -7840,7 +8772,20 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core", + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", ] [[package]] @@ -7849,11 +8794,11 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link", - "windows-result", - "windows-strings", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -7862,11 +8807,22 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core", + "windows-core 0.62.2", "windows-link", "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -7875,7 +8831,18 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -7886,7 +8853,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -7901,7 +8868,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core", + "windows-core 0.62.2", "windows-link", ] @@ -7912,8 +8879,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ "windows-link", - "windows-result", - "windows-strings", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -7925,6 +8901,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.5.1" @@ -8228,7 +9214,7 @@ dependencies = [ "heck", "indexmap 2.14.0", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -8244,7 +9230,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -8482,7 +9468,7 @@ dependencies = [ "chrono", "colored 3.1.1", "const-str", - "ctor", + "ctor 0.6.3", "dirs", "futures", "git-version", @@ -8543,7 +9529,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -8594,7 +9580,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "zvariant_utils", ] @@ -8626,7 +9612,7 @@ checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -8646,7 +9632,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -8667,7 +9653,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -8700,7 +9686,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -8746,7 +9732,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "zvariant_utils", ] @@ -8758,5 +9744,5 @@ checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] diff --git a/Cargo.toml b/Cargo.toml index 95336a4d..81a677bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,10 +2,14 @@ members = [ "crates/core", "crates/code-search", + "crates/execpolicy", "crates/tools", "crates/provider", "crates/safety", "crates/sandbox", + "crates/linux-sandbox", + "crates/sandbox-network-proxy", + "crates/windows-sandbox", "crates/tasks", "crates/mcp", "crates/network-proxy", @@ -58,6 +62,7 @@ devo-client = { path = "crates/client" } devo-code-search = { path = "crates/code-search" } devo-config = { path = "crates/config" } devo-core = { path = "crates/core" } +devo-execpolicy = { path = "crates/execpolicy" } devo-file-search = { path = "crates/file-search" } devo-keyring-store = { path = "crates/keyring-store" } devo-mcp = { path = "crates/mcp" } @@ -67,6 +72,9 @@ devo-provider = { path = "crates/provider" } devo-rmcp-client = { path = "crates/rmcp-client" } devo-safety = { path = "crates/safety" } devo-sandbox = { path = "crates/sandbox" } +devo-linux-sandbox = { path = "crates/linux-sandbox" } +devo-sandbox-network-proxy = { path = "crates/sandbox-network-proxy" } +devo-windows-sandbox = { path = "crates/windows-sandbox" } devo-server = { path = "crates/server" } devo-skills = { path = "crates/skills" } devo-tasks = { path = "crates/tasks" } @@ -94,6 +102,7 @@ itertools = "0.14.0" keyring = { version = "3.6", default-features = false } lazy_static = "1" libc = "0.2.182" +multimap = "0.10.1" model2vec = "0.3.0" notify = "8.2.0" nono = { version = "=0.53.0", default-features = false } @@ -125,6 +134,7 @@ serial_test = "3.2.0" sha2 = "0.10" shlex = "1.3.0" smol_str = { version = "0.3", features = ["serde"] } +starlark = { version = "0.14.2", default-features = false } strum = "0.27.2" strum_macros = "0.28.0" supports-color = "3.0.2" diff --git a/README.ja.md b/README.ja.md index 2845b901..c55d0115 100644 --- a/README.ja.md +++ b/README.ja.md @@ -83,7 +83,7 @@ Desktop 体験、terminal workflow、ランタイムの動作、ワークスペ GLM 5.2 MiniMax M3 Qwen3 Coder Next - Kimi K2.5 + Kimi K3

Devo の組み込みモデルカタログには、Qwen、Kimi、MiniMax、GLM、DeepSeek の検証済みモデル定義が含まれています。 diff --git a/README.md b/README.md index ac7f00c6..99417e77 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ runtime behavior, and workspace execution under your control. GLM 5.2 MiniMax M3 Qwen3 Coder Next - Kimi K2.5 + Kimi K3

Devo's built-in model catalog includes tested model definitions for Qwen, Kimi, diff --git a/README.ru.md b/README.ru.md index 11aba066..3005abd7 100644 --- a/README.ru.md +++ b/README.ru.md @@ -90,7 +90,7 @@ Devo предназначен для команд, которым нужен cod GLM 5.2 MiniMax M3 Qwen3 Coder Next - Kimi K2.5 + Kimi K3

Встроенный каталог моделей Devo содержит проверенные определения моделей для diff --git a/README.zh-Hans.md b/README.zh-Hans.md index 11d04c10..0dbe9061 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -77,7 +77,7 @@ Desktop 体验、终端工作流以及工作区执行边界的团队。 GLM 5.2 MiniMax M3 Qwen3 Coder Next - Kimi K2.5 + Kimi K3

Devo 的内置模型目录包含 Qwen、Kimi、MiniMax、GLM 和 DeepSeek 的已测试模型定义。 diff --git a/README.zh-Hant.md b/README.zh-Hant.md index 6eda75e1..97d29e40 100644 --- a/README.zh-Hant.md +++ b/README.zh-Hant.md @@ -77,7 +77,7 @@ Desktop 體驗、終端機工作流以及工作區執行邊界的團隊。 GLM 5.2 MiniMax M3 Qwen3 Coder Next - Kimi K2.5 + Kimi K3

Devo 的內建模型目錄包含 Qwen、Kimi、MiniMax、GLM 和 DeepSeek 的已測試模型定義。 diff --git a/apps/desktop/src/main/tray-menu.test.ts b/apps/desktop/src/main/tray-menu.test.ts index d0e63d89..d6b61692 100644 --- a/apps/desktop/src/main/tray-menu.test.ts +++ b/apps/desktop/src/main/tray-menu.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { MenuItemConstructorOptions } from "electron" -import { buildCodexStyleTrayMenuTemplate } from "./tray-menu" +import { buildDevoTrayMenuTemplate } from "./tray-menu" function menuShape(items: MenuItemConstructorOptions[]): unknown[] { return items.map((item) => ({ @@ -13,8 +13,8 @@ function menuShape(items: MenuItemConstructorOptions[]): unknown[] { })) } -describe("buildCodexStyleTrayMenuTemplate", () => { - test("builds a Codex-style tray menu with running, recent, usage, and actions", () => { +describe("buildDevoTrayMenuTemplate", () => { + test("builds a Devo tray menu with running, recent, usage, and actions", () => { const liveSessions = new Map([ [ "s1", @@ -74,7 +74,7 @@ describe("buildCodexStyleTrayMenuTemplate", () => { }, ] - const template = buildCodexStyleTrayMenuTemplate({ + const template = buildDevoTrayMenuTemplate({ liveSessions, discovery: { projects: [], diff --git a/apps/desktop/src/main/tray-menu.ts b/apps/desktop/src/main/tray-menu.ts index ce6ea005..c1807004 100644 --- a/apps/desktop/src/main/tray-menu.ts +++ b/apps/desktop/src/main/tray-menu.ts @@ -11,7 +11,7 @@ export interface DiscoveryCache { sessions: Session[] } -export interface CodexStyleTrayMenuOptions { +export interface DevoTrayMenuOptions { liveSessions: ReadonlyMap discovery: DiscoveryCache | null pendingCount: number @@ -40,8 +40,8 @@ interface UsageSummary { cacheReadTokens: number } -export function buildCodexStyleTrayMenuTemplate( - options: CodexStyleTrayMenuOptions, +export function buildDevoTrayMenuTemplate( + options: DevoTrayMenuOptions, ): MenuItemConstructorOptions[] { const template: MenuItemConstructorOptions[] = [] diff --git a/apps/desktop/src/main/tray.ts b/apps/desktop/src/main/tray.ts index 69b93b1d..f6afff87 100644 --- a/apps/desktop/src/main/tray.ts +++ b/apps/desktop/src/main/tray.ts @@ -23,7 +23,7 @@ import { type SessionState, } from "./notification-watcher" import { getAcpTransport, getServerUrl, onServerReady } from "./devo-manager" -import { buildCodexStyleTrayMenuTemplate, type DiscoveryCache } from "./tray-menu" +import { buildDevoTrayMenuTemplate, type DiscoveryCache } from "./tray-menu" const log = createLogger("tray") @@ -182,7 +182,7 @@ function rebuildMenu(): void { const liveSessions = getSessionStates() const pendingCount = getPendingCount() - const template = buildCodexStyleTrayMenuTemplate({ + const template = buildDevoTrayMenuTemplate({ liveSessions, discovery: discoveryCache, pendingCount, diff --git a/apps/desktop/src/renderer/atoms/derived/session-metrics.ts b/apps/desktop/src/renderer/atoms/derived/session-metrics.ts index 5e5a797b..5c885b12 100644 --- a/apps/desktop/src/renderer/atoms/derived/session-metrics.ts +++ b/apps/desktop/src/renderer/atoms/derived/session-metrics.ts @@ -7,7 +7,7 @@ */ import { atom } from "jotai" import { atomFamily } from "jotai-family" -import { getToolCategory } from "../../components/chat/tool-card" +import { getToolCategory } from "../../components/chat/tool-category" import { computeSessionMetricsExtended, formatCost, diff --git a/apps/desktop/src/renderer/components/chat/chat-tool-call.test.ts b/apps/desktop/src/renderer/components/chat/chat-tool-call.test.ts index 68680ad8..2c90923a 100644 --- a/apps/desktop/src/renderer/components/chat/chat-tool-call.test.ts +++ b/apps/desktop/src/renderer/components/chat/chat-tool-call.test.ts @@ -1,48 +1,67 @@ import { readFileSync } from "node:fs" import { describe, expect, test } from "bun:test" -import { getToolDuration, getToolSubtitle, shouldDefaultOpen } from "./chat-tool-call" +import { buildBashTerminalOutput, getToolSubtitle, stripShellEnvelope } from "./chat-tool-call" const elapsedHookSource = readFileSync(new URL("../../hooks/use-elapsed-time.ts", import.meta.url), "utf8") const chatToolCallSource = readFileSync(new URL("./chat-tool-call.tsx", import.meta.url), "utf8") const rendererCssSource = readFileSync(new URL("../../index.css", import.meta.url), "utf8") -describe("shouldDefaultOpen", () => { - test("keeps all tool output collapsed by default", () => { +describe("buildBashTerminalOutput", () => { + test("joins command and output into a single terminal block", () => { expect({ - bash: shouldDefaultOpen("bash", "completed"), - read: shouldDefaultOpen("read", "completed"), - bashError: shouldDefaultOpen("bash", "error"), - readError: shouldDefaultOpen("read", "error"), - unknown: shouldDefaultOpen("unknown", "error"), + plain: buildBashTerminalOutput("bun test", "ok\nDone", undefined), + echoed: buildBashTerminalOutput("bun test", "$ bun test\nok", undefined), + errorPreferred: buildBashTerminalOutput("bun test", "partial", "boom"), + pending: buildBashTerminalOutput("bun test", undefined, undefined), + noCommand: buildBashTerminalOutput(undefined, "plain output", undefined), }).toEqual({ - bash: false, - read: false, - bashError: false, - readError: false, - unknown: false, + plain: "$ bun test\nok\nDone", + echoed: "$ bun test\nok", + errorPreferred: "$ bun test\nboom", + pending: "$ bun test", + noCommand: "plain output", }) }) -}) -describe("getToolDuration", () => { - test("uses SDK tool state start and end timestamps", () => { - expect( - getToolDuration({ - id: "tool-1", - type: "tool", - state: { status: "completed", time: { start: 1_000, end: 3_500 } }, - } as any), - ).toBe("2s") + test("truncates very long output", () => { + const truncated = buildBashTerminalOutput(undefined, "x".repeat(6000), undefined) + expect({ + endsWithMarker: truncated.endsWith("... (truncated)"), + length: truncated.length, + }).toEqual({ + endsWithMarker: true, + length: 5000 + "\n... (truncated)".length, + }) }) +}) - test("clamps reversed timestamps instead of showing negative durations", () => { - expect( - getToolDuration({ - id: "tool-1", - type: "tool", - state: { status: "completed", time: { start: 3_500, end: 1_000 } }, - } as any), - ).toBe("0ms") +describe("stripShellEnvelope", () => { + test("strips the shell result envelope from tool output", () => { + const envelope = JSON.stringify({ + output: "", + command: "ls", + exit: 0, + description: "List files", + cwd: "/repo", + yield_time_ms: 1000, + }) + expect({ + envelopeOnly: stripShellEnvelope(envelope), + stdoutPlusEnvelope: stripShellEnvelope(`hello\nworld\n${envelope}`), + plainOutput: stripShellEnvelope("just text"), + otherJson: stripShellEnvelope('{"foo": 1}'), + envelopeWithOutput: stripShellEnvelope( + JSON.stringify({ output: "files", command: "ls", exit: 0 }), + ), + cmdEnvelope: stripShellEnvelope(JSON.stringify({ output: "ok", cmd: "ls", exit: 0 })), + }).toEqual({ + envelopeOnly: "", + stdoutPlusEnvelope: "hello\nworld", + plainOutput: "just text", + otherJson: '{"foo": 1}', + envelopeWithOutput: "files", + cmdEnvelope: "ok", + }) }) }) @@ -140,3 +159,16 @@ describe("useToolElapsedTime source", () => { }) }) }) + + +describe("ChatToolCall memo comparison", () => { + test("re-renders when the controlled open state changes so rows can expand", () => { + expect({ + comparesOpen: chatToolCallSource.includes("prev.open !== next.open"), + comparesTurnError: chatToolCallSource.includes("prev.turnHasError !== next.turnHasError"), + }).toEqual({ + comparesOpen: true, + comparesTurnError: true, + }) + }) +}) diff --git a/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx b/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx index 696d59bc..3001d557 100644 --- a/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx +++ b/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx @@ -7,13 +7,7 @@ import { CodeBlockTitle, } from "@devo/ui/components/ai-elements/code-block" import { Diff, DiffContent } from "@devo/ui/components/ai-elements/diff" -import { - Terminal, - TerminalContent, - TerminalCopyButton, - TerminalHeader, - TerminalTitle, -} from "@devo/ui/components/ai-elements/terminal" +import { Terminal, TerminalContent } from "@devo/ui/components/ai-elements/terminal" import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@devo/ui/components/dialog" import { cn } from "@devo/ui/lib/utils" @@ -45,7 +39,7 @@ import { viewFileInDiffPanelAtom } from "../../atoms/ui" import { detectContentLanguage, detectLanguage, prettyPrintJson } from "../../lib/language" import type { FilePart, ToolPart, ToolStateCompleted } from "../../lib/types" import { SubAgentCard } from "./sub-agent-card" -import type { ToolCategory } from "./tool-card" +import type { ToolCategory } from "./tool-category" import { TranscriptDisclosure, TranscriptDisclosureContent, @@ -71,23 +65,63 @@ function truncateOutput(output: string, max = MAX_OUTPUT_LENGTH): string { return `${output.slice(0, max)}\n... (truncated)` } +/** Shell-family tools that share the command/terminal display. */ +const SHELL_TOOLS: ReadonlySet = new Set(["bash", "shell_command", "exec_command"]) + +/** + * The shell tool's result text is `\n`, where the + * envelope carries display metadata ({output, command, exit, description, + * cwd, yield_time_ms}). Strip it so the terminal shows only real command + * output; the command and description already appear in the tool row itself. + */ +export function stripShellEnvelope(output: string): string { + const parseEnvelope = (text: string): { body?: string } | undefined => { + try { + const parsed = JSON.parse(text) + if ( + parsed && + typeof parsed === "object" && + typeof parsed.exit === "number" && + (typeof parsed.command === "string" || typeof parsed.cmd === "string") + ) { + return { body: typeof parsed.output === "string" ? parsed.output : undefined } + } + } catch { + // Not JSON — ordinary command output. + } + return undefined + } + + const trimmed = output.trim() + // The output IS the envelope (the command produced no stdout). + if (trimmed.startsWith("{")) { + const whole = parseEnvelope(trimmed) + if (whole) return whole.body ?? "" + } + // Stdout followed by the envelope JSON (they are joined with "\n"). + const separator = output.lastIndexOf("\n{") + if (separator !== -1) { + const tail = parseEnvelope(output.slice(separator + 1).trim()) + if (tail) return output.slice(0, separator).trimEnd() + } + return output +} + // ============================================================ -// Read output parsing — strip cat -n prefixes and tags +// Read output parsing — strip wrapper tags, keep original line numbers // ============================================================ -/** Pre-compiled regex for line-number formats (hoisted to avoid re-creation per call) */ -const LINE_NUM_REGEX = /^\s*(\d+)[|:\t]\s?(.*)$/ - /** * Parses output from various read tools. * Handles: * 1. Claude Code's `cat -n` format: 00001| content * 2. Devo's XML-wrapped format: ...1: content * - * This function strips the wrapper tags, removes the line-number - * prefixes, and returns the clean content + the starting line number. + * Strips the wrapper tags and trailing metadata lines, but keeps the + * content's own line-number prefixes — the read output already carries + * line numbers, so the UI must not add another gutter of its own. */ -function parseReadOutput(raw: string): { content: string; startLine: number } { +function parseReadOutput(raw: string): string { let text = raw // 1. Strip Devo XML-style tags @@ -113,28 +147,7 @@ function parseReadOutput(raw: string): { content: string; startLine: number } { text = text.replace(/\n?\s*\(File has more lines[^)]*\)\s*$/, "") text = text.replace(/\n?\s*\(Output truncated[^)]*\)\s*$/, "") - const lines = text.split("\n") - // If first line has a line number, use it as startLine - const firstMatch = lines[0]?.match(LINE_NUM_REGEX) - - if (!firstMatch) { - return { content: text, startLine: 1 } - } - - const startLine = Number.parseInt(firstMatch[1], 10) - const stripped: string[] = [] - - for (const line of lines) { - const match = line.match(LINE_NUM_REGEX) - if (match) { - stripped.push(match[2]) - } else { - // Lines without prefix (e.g. blank lines) — keep as-is - stripped.push(line) - } - } - - return { content: stripped.join("\n"), startLine } + return text } // ============================================================ @@ -186,6 +199,8 @@ export function getToolInfo(tool: string): { case "webfetch": return { icon: GlobeIcon, title: "Fetch" } case "bash": + case "shell_command": + case "exec_command": return { icon: TerminalIcon, title: "Shell" } case "edit": return { icon: EditIcon, title: "Edit" } @@ -241,6 +256,8 @@ function getPendingLabel(tool: string): string { case "apply_patch": return "Preparing patch..." case "bash": + case "shell_command": + case "exec_command": return "Preparing command..." case "read": return "Preparing read..." @@ -294,11 +311,14 @@ export function getToolSubtitle( extractFromRaw(state, "pattern", "path") break case "bash": + case "shell_command": + case "exec_command": subtitle = title ?? (input.description as string) ?? (input.command as string) ?? - extractFromRaw(state, "command", "description") + (input.cmd as string) ?? + extractFromRaw(state, "command", "cmd", "description") break case "edit": subtitle = @@ -394,27 +414,38 @@ function formatInputParams(input: Record): string | undefined { return `[${parts.join(", ")}]` } -/** Compute completed tool duration from the SDK tool-state timestamps. */ -export function getToolDuration(part: ToolPart): string | undefined { - const state = part.state - if (state.status === "completed" || state.status === "error") { - const ms = Math.max(0, state.time.end - state.time.start) - if (ms < 1000) return `${ms}ms` - const seconds = Math.floor(ms / 1000) - if (seconds < 60) return `${seconds}s` - const minutes = Math.floor(seconds / 60) - const remainingSeconds = seconds % 60 - return `${minutes}m ${remainingSeconds}s` - } - return undefined -} - // ============================================================ // Tool-specific content renderers // ============================================================ /** - * Bash tool: shows command with syntax highlighting + ANSI-colored terminal output. + * Builds the text of the single terminal block shown for a bash tool call: + * the command as its first line, followed by the (cleaned) output. + * + * The SDK output often echoes the command as the first line (e.g. + * "$ command\n..."). Since we prepend the command ourselves, strip the + * duplicate before joining. + */ +export function buildBashTerminalOutput( + command: string | undefined, + output: string | undefined, + error: string | undefined, +): string { + let body = stripShellEnvelope(error ?? output ?? "") + if (command) { + const prefix = `$ ${command}` + if (body.startsWith(prefix)) { + body = body.slice(prefix.length).replace(/^\r?\n/, "") + } + } + body = truncateOutput(body) + if (!command) return body + return body ? `$ ${command}\n${body}` : `$ ${command}` +} + +/** + * Bash tool: one terminal block — command on the first line, ANSI-colored + * output below. No separate command code block or "Output" header. * * During `running` state the server streams incremental output via * `state.metadata.output` (accumulated string, updated on every stdout/stderr @@ -422,7 +453,9 @@ export function getToolDuration(part: ToolPart): string | undefined { * behaviour of the Devo TUI and web UI. */ function BashContent({ part }: { part: ToolPart }) { - const command = part.state.input?.command as string | undefined + const command = + (part.state.input?.command as string | undefined) ?? + (part.state.input?.cmd as string | undefined) // During "running", live output arrives in state.metadata.output. // After completion it moves to state.output. @@ -434,68 +467,27 @@ function BashContent({ part }: { part: ToolPart }) { const error = part.state.status === "error" ? (part.state as { error: string }).error : undefined const isStreaming = part.state.status === "running" - const displayOutput = useMemo(() => { - if (error) return error - if (!output) return "" - let cleaned = output - // The SDK output often echoes the command as the first line (e.g. "$ command\n..."). - // Since we already render the command in a separate CodeBlock above, strip the duplicate. - if (command) { - const prefix = `$ ${command}` - if (cleaned.startsWith(prefix)) { - cleaned = cleaned.slice(prefix.length).replace(/^\r?\n/, "") - } - } - return truncateOutput(cleaned) - }, [output, error, command]) - - // If there's meaningful output, show a terminal view with ANSI support - if (displayOutput || isStreaming) { - return ( -
- {command && ( - - - - )} - - - Output - - - - -
- ) - } + const terminalOutput = useMemo( + () => buildBashTerminalOutput(command, output, error), + [command, output, error], + ) - // Command only (no output yet) - if (command) { - return ( - - - - ) - } + if (!terminalOutput && !isStreaming) return null - return null + return ( + + + + ) } /** * Edit tool: shows inline diff of oldString → newString with syntax highlighting. - * No inner headers — diff stats are shown in the ToolCard trailing area. + * No inner headers — diff stats are shown in the tool row's trailing area. */ function EditDiffContent({ part }: { part: ToolPart }) { const filePath = (part.state.input?.filePath as string) ?? (part.state.input?.path as string) @@ -535,7 +527,7 @@ function EditDiffContent({ part }: { part: ToolPart }) { /** * Write tool: shows syntax-highlighted file content being written. - * No inner header — the ToolCard header already shows the filename. + * No inner header — the tool row header already shows the filename. */ function WriteContent({ part }: { part: ToolPart }) { const filePath = (part.state.input?.filePath as string) ?? (part.state.input?.path as string) @@ -575,7 +567,7 @@ function WriteContent({ part }: { part: ToolPart }) { /** * Apply patch tool: shows diff in patch mode. - * No inner header — the ToolCard header identifies this as a patch. + * No inner header — the tool row header identifies this as a patch. */ function PatchContent({ part }: { part: ToolPart }) { const output = part.state.status === "completed" ? part.state.output : undefined @@ -618,7 +610,8 @@ function PatchContent({ part }: { part: ToolPart }) { /** * Read tool: shows syntax-highlighted file contents. - * Strips cat -n prefixes and tags, preserves original line numbers. + * Strips wrapper tags but keeps the read output's own line numbers — + * the code block renders no line-number gutter of its own. */ function ReadContent({ part }: { part: ToolPart }) { const filePath = (part.state.input?.filePath as string) ?? (part.state.input?.path as string) @@ -630,25 +623,16 @@ function ReadContent({ part }: { part: ToolPart }) { const language = (detectLanguage(filePath) ?? "text") as BundledLanguage - // Parse out cat -n line numbers and wrapper - const { content: cleanContent, startLine } = parseReadOutput(output) - const displayContent = truncateOutput(cleanContent) + // No inner header — the tool row header already shows the filename. + const displayContent = truncateOutput(parseReadOutput(output)) - // No inner header — the ToolCard header already shows the filename. - // Just the code block with correct line number offset. return ( - + ) } @@ -838,6 +822,26 @@ function GenericContent({ part }: { part: ToolPart }) { // Tool group summaries // ============================================================ +/** + * Verb for an explore tool group: specific when every tool in the group is + * the same kind (e.g. a run of greps reads "Searched", not "Read"). + */ +function exploreGroupVerb(tools: ToolPart[]): string { + const first = tools[0]?.tool + if (!first || !tools.every((t) => t.tool === first)) return "Explored" + switch (first) { + case "read": + return "Read" + case "grep": + case "glob": + return "Searched" + case "list": + return "Listed" + default: + return "Explored" + } +} + export function describeToolGroup( category: ToolCategory, tools: ToolPart[], @@ -857,7 +861,7 @@ export function describeToolGroup( if (details.length > 0) { switch (category) { case "explore": - return count === 1 ? `Read ${details[0]}` : `Read ${details.join(", ")}` + return `${exploreGroupVerb(tools)} ${count === 1 ? details[0] : details.join(", ")}` case "edit": return count === 1 ? `Edited ${details[0]}` : `Edited ${details.join(", ")}` case "run": @@ -877,8 +881,10 @@ export function describeToolGroup( } switch (category) { - case "explore": - return `Explored ${count} files` + case "explore": { + const verb = exploreGroupVerb(tools) + return `${verb} ${count} ${verb === "Searched" ? "patterns" : "files"}` + } case "edit": return `Edited ${count} files` case "run": @@ -904,22 +910,12 @@ export function isGroupError(tools: ToolPart[]): boolean { return tools.some((t) => t.state.status === "error") } -// ============================================================ -// Smart defaults: which tools should be open by default -// ============================================================ - -/** - * Returns whether a tool should default to expanded in the active turn. - */ -export function shouldDefaultOpen(_tool: string, _status: string): boolean { - return false -} /** * Returns whether a tool has expandable content. */ function hasExpandableContent(part: ToolPart): boolean { const { tool, state } = part - // Task uses SubAgentCard, not ToolCard + // Task uses SubAgentCard, not a tool row if (tool === "task") return false // Todowrite has expandable todo items if (tool === "todowrite" || tool === "todoread") { @@ -940,8 +936,8 @@ function hasExpandableContent(part: ToolPart): boolean { // If there's output or error, there's content if (state.status === "completed" && state.output) return true if (state.status === "error") return true - // Bash always has content (the command at least) - if (tool === "bash") return true + // Shell tools always have content (the command at least) + if (SHELL_TOOLS.has(tool)) return true return false } @@ -952,7 +948,7 @@ function getToolContent(part: ToolPart): ReactNode { const error = part.state.status === "error" ? (part.state as { error: string }).error : undefined if ( error && - part.tool !== "bash" && + !SHELL_TOOLS.has(part.tool) && part.tool !== "edit" && part.tool !== "write" && part.tool !== "read" && @@ -963,6 +959,8 @@ function getToolContent(part: ToolPart): ReactNode { switch (part.tool) { case "bash": + case "shell_command": + case "exec_command": return case "edit": return @@ -992,8 +990,6 @@ function getToolContent(part: ToolPart): ReactNode { interface ChatToolCallProps { part: ToolPart - /** Whether this tool is in the active (last) turn */ - isActiveTurn?: boolean /** Whether the turn containing this tool has an error (enables delete action) */ turnHasError?: boolean /** Delete this tool part (for error recovery) */ @@ -1038,13 +1034,12 @@ function areToolPartsEqual(a: ToolPart, b: ToolPart): boolean { } /** - * Renders a single tool call as a ToolCard with tool-specific content, - * or as a SubAgentCard for sub-agent tasks. + * Renders a single tool call as a unified disclosure row with tool-specific + * content, or as a SubAgentCard for sub-agent tasks. */ export const ChatToolCall = memo( function ChatToolCall({ part, - isActiveTurn = false, turnHasError = false, onDelete, projectRoot, @@ -1082,7 +1077,7 @@ export const ChatToolCall = memo( const status = part.state.status as "running" | "error" | "completed" | "pending" - // Build trailing element: diff stats + "view diff" button, or a running spinner. + // Build trailing element: diff stats, or a running spinner. const trailingElement = useMemo(() => { const parts: ReactNode[] = [] @@ -1115,29 +1110,6 @@ export const ChatToolCall = memo( return {parts} }, [diffStats, status]) - // Combine trailing element with "View diff" button for edit-category tools - const combinedTrailing = useMemo(() => { - if (!editFilePath || status !== "completed") return trailingElement - const viewButton = ( - - ) - if (!trailingElement) return viewButton - return ( - - {trailingElement} - {viewButton} - - ) - }, [editFilePath, status, trailingElement, handleViewDiff]) - // When the turn has an error, add a delete button so the user can // surgically remove a problematic tool part and continue the conversation. const handleDelete = useCallback( @@ -1149,7 +1121,7 @@ export const ChatToolCall = memo( ) const finalTrailing = useMemo(() => { - if (!turnHasError || !onDelete) return combinedTrailing + if (!turnHasError || !onDelete) return trailingElement const deleteButton = ( ) - if (!combinedTrailing) return deleteButton + if (!trailingElement) return deleteButton return ( - {combinedTrailing} + {trailingElement} {deleteButton} ) - }, [turnHasError, onDelete, combinedTrailing, handleDelete]) + }, [turnHasError, onDelete, trailingElement, handleDelete]) // Skip rendering todoread parts without output if (part.tool === "todoread" && part.state.status !== "completed") return null @@ -1178,12 +1150,11 @@ export const ChatToolCall = memo( return } - // --- All other tools (including todos): ToolCard --- + // --- All other tools (including todos): unified tool row --- const { icon: Icon, title } = getToolInfo(part.tool) const subtitle = getToolSubtitle(part, { projectRoot }) const hasContent = hasExpandableContent(part) - const defaultOpen = - defaultOpenProp ?? (isActiveTurn ? shouldDefaultOpen(part.tool, status) : false) + const defaultOpen = defaultOpenProp ?? false const isRunning = status === "running" || status === "pending" // Extract attachments @@ -1201,10 +1172,13 @@ export const ChatToolCall = memo( {title} ) + // Completed edit-category tools can jump to the diff panel; the action + // lives at the top of the expanded content so the row trailing stays lean. + const showViewDiff = editFilePath != null && status === "completed" + return (
{hasContent && ( - + + {showViewDiff && ( +
+ +
+ )} {getToolContent(part)}
)} @@ -1236,10 +1223,12 @@ export const ChatToolCall = memo( }, (prev, next) => { if (!areToolPartsEqual(prev.part, next.part)) return false - if (prev.isActiveTurn !== next.isActiveTurn) return false + // open is controlled by the parent timeline (expandedRowIds); without this + // comparison the memo blocks the re-render and the row can never expand. + if (prev.open !== next.open) return false if (prev.turnHasError !== next.turnHasError) return false if (prev.projectRoot !== next.projectRoot) return false - // onDelete is a callback ref - skip reference comparison to avoid + // onDelete/onOpenChange are callback refs - skip reference comparison to avoid // re-renders from parent creating new closures return true }, diff --git a/apps/desktop/src/renderer/components/chat/chat-turn.test.ts b/apps/desktop/src/renderer/components/chat/chat-turn.test.ts index 0cb61cad..f7106221 100644 --- a/apps/desktop/src/renderer/components/chat/chat-turn.test.ts +++ b/apps/desktop/src/renderer/components/chat/chat-turn.test.ts @@ -139,7 +139,7 @@ describe("ChatTurnComponent transcript controls", () => { expect({ definesWorkingStrip: source.includes("function WorkingTurnStatusStrip"), - usesWorkingForCopy: source.includes('Working for "'), + usesWorkingForCopy: source.includes("Working for {display}"), reusesTurnDuration: source.includes( "computeTurnWorkTime(turn, { active: true })", ), diff --git a/apps/desktop/src/renderer/components/chat/chat-turn.tsx b/apps/desktop/src/renderer/components/chat/chat-turn.tsx index ba186b18..6a2919a0 100644 --- a/apps/desktop/src/renderer/components/chat/chat-turn.tsx +++ b/apps/desktop/src/renderer/components/chat/chat-turn.tsx @@ -583,10 +583,10 @@ function CompletedTurnProcessDisclosure({ * Renders a single turn: user message + assistant response. * * Two modes based on turn state: - * - **Active turn** (last + working): tool calls are individually rendered with - * per-tool ToolCards, smart default expand/collapse, and live activity. - * - **Completed turn**: icon-pill summary bar with one-click expand to show - * individual tools. Response text is always visible. + * - **Active turn** (last + working): the process timeline streams live — + * interleaved thoughts, unified tool rows, and text. + * - **Completed turn**: the process collapses behind a "Worked for ..." + * disclosure; the final response text is always visible. * * Display mode preference (default/verbose) modifies behavior: * - default: interleaved text + grouped tool summaries as collapsible rows. @@ -710,8 +710,6 @@ export const ChatTurnComponent = memo( return "" }, [turn.assistantMessages]) - // Determine if tools should be shown individually (active turn behavior) - const isActiveTurn = working const showVerboseTools = displayMode === "verbose" const textAlreadyInline = @@ -812,7 +810,6 @@ export const ChatTurnComponent = memo( + - + {tools.map((tool) => ( @@ -82,7 +74,6 @@ export interface ProcessTimelineViewProps { items: ProcessTimelineItem[] orderedParts: ProcessTimelineInput[] working: boolean - isActiveTurn: boolean projectRoot?: string | null defaultExpandAll?: boolean expandedRowIds?: Set @@ -96,7 +87,6 @@ export const ProcessTimelineView = memo(function ProcessTimelineView({ items, orderedParts, working, - isActiveTurn, projectRoot, defaultExpandAll = false, expandedRowIds, @@ -144,7 +134,6 @@ export const ProcessTimelineView = memo(function ProcessTimelineView({ onToggleRow(rowId, open) : undefined} open={expandedRowIds ? expandedRowIds.has(rowId) : undefined} projectRoot={projectRoot} diff --git a/apps/desktop/src/renderer/components/chat/process-timeline.ts b/apps/desktop/src/renderer/components/chat/process-timeline.ts index 3b317e2d..21e41818 100644 --- a/apps/desktop/src/renderer/components/chat/process-timeline.ts +++ b/apps/desktop/src/renderer/components/chat/process-timeline.ts @@ -1,5 +1,5 @@ import type { ReasoningPart, ToolPart } from "../../lib/types" -import { getToolCategory, type ToolCategory } from "./tool-card" +import { getToolCategory, type ToolCategory } from "./tool-category" export type ProcessTimelineInput = | { kind: "tool"; part: ToolPart } diff --git a/apps/desktop/src/renderer/components/chat/sub-agent-card.tsx b/apps/desktop/src/renderer/components/chat/sub-agent-card.tsx index b8d12adb..39e92145 100644 --- a/apps/desktop/src/renderer/components/chat/sub-agent-card.tsx +++ b/apps/desktop/src/renderer/components/chat/sub-agent-card.tsx @@ -20,7 +20,7 @@ import { appStore } from "../../atoms/store" import { getStreamingPartsForSession, streamingVersionFamily } from "../../atoms/streaming" import { useToolElapsedTime } from "../../hooks/use-elapsed-time" import type { ToolPart, ToolState } from "../../lib/types" -import { getToolDuration, getToolInfo, getToolSubtitle } from "./chat-tool-call" +import { getToolInfo, getToolSubtitle } from "./chat-tool-call" // ============================================================ // Collapse state for three-tier agent card @@ -180,8 +180,7 @@ export const SubAgentCard = memo(function SubAgentCard({ part: propPart }: SubAg setCollapseState("summary") }, []) - // ── Duration ─────────────────────────────────────────────── - const duration = getToolDuration(part) + // ── Elapsed time (live, while running) ───────────────────── const elapsedTime = useToolElapsedTime(part) // Access child session data from the store. @@ -297,15 +296,16 @@ export const SubAgentCard = memo(function SubAgentCard({ part: propPart }: SubAg return (
-
+
-
+
{/* Waiting indicator: shown when sub-agent has a pending permission or question */} {childIsWaiting && childHasPendingPermission && ( @@ -340,9 +339,6 @@ export const SubAgentCard = memo(function SubAgentCard({ part: propPart }: SubAg )} {isRunning && !childIsWaiting && } {childIsWaiting && } - {!isRunning && duration && ( - {duration} - )} {sessionId && (