diff --git a/crates/ox-core/src/config.rs b/crates/ox-core/src/config.rs index 879f51d78..4e9c6607e 100644 --- a/crates/ox-core/src/config.rs +++ b/crates/ox-core/src/config.rs @@ -29,15 +29,15 @@ pub(crate) use self::base_url::BaseUrl; use self::compaction::{AutoCompactionConfig, CompactionConfig}; use self::credentials::{AuthState, CredentialError}; use self::gateway::{GatewayCompat, GatewaySnapshot, ResolvedGateway}; -use self::load::Loaded; +use self::load::{Loaded, ParsedPermission}; use self::merge::Origins; use self::provenance::{Provenance, Source, Sourced, TierPattern}; -use self::schema::{ClientAuth, FileConfig, PermissionTable, ThemeTable, TierTable, TuiTable}; +use self::schema::{ClientAuth, FileConfig, PermissionTable, ThemeTable, TuiTable}; use crate::auth::oauth::ClaudeCodeKeychain; use crate::auth::{self, AuthSource, LoginUnavailable}; use crate::model::{Effort, ModelSettings, ModelSpec, PromptCacheTtl}; use crate::permission::defaults::Shipped; -use crate::permission::{Mode, Policy, Tier, Workspace}; +use crate::permission::{Mode, Policy, Workspace}; use crate::util::env; use crate::util::path::{expand_absolute, expand_user, project_root, tildify}; use crate::util::tls::{PlatformTrust, load_extra_ca_certs}; @@ -259,7 +259,11 @@ impl Config { /// Resolves the config, checking the CA bundle it picks with `check_ca_bundle`. fn resolve(cwd: &Path, check_ca_bundle: fn(&Path) -> Result<()>) -> Result { - let Loaded { config, origins } = load::load(cwd, &project_root(cwd))?; + let Loaded { + config, + origins, + permission: parsed_permission, + } = load::load(cwd, &project_root(cwd))?; let FileConfig { client, permission, @@ -308,7 +312,7 @@ impl Config { model.resolve_max_tokens(max_tokens.value), )?; - let permission = resolve_permission(permission, &origins, cwd)?; + let permission = resolve_permission(permission, parsed_permission, &origins, cwd)?; let tui = resolve_tui(tui, &origins)?; @@ -654,25 +658,21 @@ struct ResolvedPermission { /// `OX_PERMISSION_MODE`, which wins over the file mode. fn resolve_permission( file: PermissionTable, + parsed: ParsedPermission, origins: &Origins, cwd: &Path, ) -> Result { - let roots = file.extra_roots.unwrap_or_default(); - let workspace = Workspace::resolve(cwd, &roots)?; - let extra_roots = origins.sourced_elements("permission.extra_roots", roots); + let ParsedPermission { + roots, + tiers: [deny, ask, allow], + extra_roots, + rules, + } = parsed; + let workspace = Workspace::with_roots(cwd, roots); let mode = env_parse::("OX_PERMISSION_MODE")? .or_else(|| origins.sourced("permission.mode", file.mode)) .unwrap_or_else(|| Sourced::fallback(Mode::default())); - let tiers = [ - (Tier::Deny, file.deny.unwrap_or_default()), - (Tier::Ask, file.ask.unwrap_or_default()), - (Tier::Allow, file.allow.unwrap_or_default()), - ]; - let rules = tiers - .iter() - .flat_map(|(tier, table)| tier_patterns(*tier, table, origins)) - .collect(); let disabled_defaults: Vec<_> = file .defaults .unwrap_or_default() @@ -685,9 +685,8 @@ fn resolve_permission( ) }) .collect(); - let [deny, ask, allow] = tiers.map(|(tier, table)| table.rules(tier)); let disabled: Vec<_> = disabled_defaults.iter().map(|group| group.value).collect(); - let policy = Policy::resolve(mode.value, deny?, ask?, allow?, &disabled, &workspace)?; + let policy = Policy::resolve(mode.value, deny, ask, allow, &disabled, &workspace)?; Ok(ResolvedPermission { policy, workspace, @@ -698,22 +697,6 @@ fn resolve_permission( }) } -/// The patterns of the `tier` table, each with the layer that added it. -fn tier_patterns(tier: Tier, table: &TierTable, origins: &Origins) -> Vec> { - table - .classes() - .into_iter() - .flat_map(|(class, patterns)| { - let patterns = patterns.iter().flatten().map(|pattern| TierPattern { - tier, - class, - pattern: pattern.clone(), - }); - origins.sourced_elements(&format!("permission.{tier}.{class}"), patterns) - }) - .collect() -} - // ── Env Overrides ── fn env_string(var: &'static str) -> Option> { @@ -763,7 +746,7 @@ mod tests { use crate::auth::Auth; use crate::auth::oauth::LoginError; use crate::auth::oauth::testing::{creds_json, write_creds}; - use crate::permission::{Access, Decision, PermissionClass}; + use crate::permission::{Access, Decision, PermissionClass, Tier}; use crate::util::fs::testing::deny_read_access; use crate::util::path::testing::HOME_VAR; use crate::util::tls::local_testing::TEST_CA_PEM; diff --git a/crates/ox-core/src/config/load.rs b/crates/ox-core/src/config/load.rs index 857088d0d..0ba608ea6 100644 --- a/crates/ox-core/src/config/load.rs +++ b/crates/ox-core/src/config/load.rs @@ -8,10 +8,11 @@ use toml::Table; use tracing::debug; use super::merge::{Origins, merge}; -use super::provenance::Layer; -use super::schema::FileConfig; +use super::provenance::{Layer, Source, Sourced, TierPattern}; +use super::schema::{FileConfig, PermissionTable, TierTable}; use crate::permission::project::{ProjectFileError, resolve_project_file}; -use crate::permission::{Tier, extra_root}; +use crate::permission::rule::Rule; +use crate::permission::{Anchor, Tier}; use crate::util::path::{XdgBase, app_dir, tildify}; use crate::util::text::list_with_and; use crate::util::toml::{error_location, redacted_message}; @@ -26,6 +27,17 @@ const PROJECT_DIR: &str = ".ox"; pub(super) struct Loaded { pub(super) config: FileConfig, pub(super) origins: Origins, + pub(super) permission: ParsedPermission, +} + +/// Compiled permission entries with their configured spellings and source layers. +pub(super) struct ParsedPermission { + pub(super) roots: Vec, + /// Rules in deny / ask / allow order. + pub(super) tiers: [Vec; 3], + + pub(super) extra_roots: Vec>, + pub(super) rules: Vec>, } /// Loads the user file, its drop-ins in name order, then the project file nearest `cwd` within @@ -46,10 +58,91 @@ fn load_layers(layers: Vec) -> Result { } let (merged, origins) = merge(tables); - let config = merged + let config: FileConfig = merged .try_into() .context("invalid config after merging layers")?; - Ok(Loaded { config, origins }) + let permission = ParsedPermission::parse(&config.permission, &origins)?; + Ok(Loaded { + config, + origins, + permission, + }) +} + +// ── Permission Parsing ── + +impl ParsedPermission { + fn parse(file: &PermissionTable, origins: &Origins) -> Result { + let extra_roots = origins.sourced_elements( + "permission.extra_roots", + file.extra_roots.iter().flatten().cloned(), + ); + let roots = extra_roots + .iter() + .map(|root| { + Anchor::resolve(&root.value).with_context(|| invalid_permission_file(&root.source)) + }) + .collect::>()?; + let tables = [ + (Tier::Deny, &file.deny), + (Tier::Ask, &file.ask), + (Tier::Allow, &file.allow), + ]; + let rules: Vec<_> = tables + .into_iter() + .flat_map(|(tier, table)| { + table + .iter() + .flat_map(move |table| tier_patterns(tier, table, origins)) + }) + .collect(); + let mut tiers = [Vec::new(), Vec::new(), Vec::new()]; + for sourced in &rules { + let TierPattern { + tier, + class, + pattern, + } = &sourced.value; + let rule = Rule::parse(*class, pattern) + .with_context(|| format!("invalid permission.{tier}.{class} pattern {pattern:?}")) + .with_context(|| invalid_permission_file(&sourced.source))?; + let index = match tier { + Tier::Deny => 0, + Tier::Ask => 1, + Tier::Allow => 2, + }; + tiers[index].push(rule); + } + Ok(Self { + roots, + tiers, + extra_roots, + rules, + }) + } +} + +fn invalid_permission_file(source: &Source) -> String { + let Source::File(layer) = source else { + unreachable!("permission lists come from config files"); + }; + format!("invalid config at {}", layer.path().display()) +} + +/// The patterns of the `tier` table, each with the layer that added it. +fn tier_patterns(tier: Tier, table: &TierTable, origins: &Origins) -> Vec> { + table + .classes() + .into_iter() + .flat_map(|(class, patterns)| { + let patterns = patterns.iter().flatten().map(|pattern| TierPattern { + tier, + class, + pattern: pattern.clone(), + }); + origins.sourced_elements(&format!("permission.{tier}.{class}"), patterns) + }) + .collect() } // ── Discovery ── @@ -166,8 +259,8 @@ pub fn user_config_path() -> Option { // ── Reading ── -/// Parses and validates one layer. A missing user file returns `Ok(None)`. Unreadable files, -/// dangling drop-in symlinks, forbidden project keys, and invalid values return errors. +/// Reads one layer and validates its schema and project keys. A missing user file returns +/// `Ok(None)`. Unreadable files and dangling drop-in symlinks return errors. fn read_layer(layer: &Layer) -> Result> { let path = layer.path(); let content = match std::fs::read_to_string(path) { @@ -180,8 +273,6 @@ fn read_layer(layer: &Layer) -> Result> { } }; - // The typed parse reports unknown keys and bad values with their line and column, and the rules - // and roots resolve here so a malformed one names its file before the lists concatenate. let invalid = || format!("invalid config at {}", path.display()); // A value can be a credential, so a private key or a project file keeps only ox's own words // from a parser message: field names, expected shapes, and accepted values. @@ -211,20 +302,7 @@ fn read_layer(layer: &Layer) -> Result> { } reject_project_keys(&table, path)?; } - let config = parsed.map_err(invalid_toml)?; - let permission = &config.permission; - for (tier, table) in [ - (Tier::Deny, &permission.deny), - (Tier::Ask, &permission.ask), - (Tier::Allow, &permission.allow), - ] { - if let Some(table) = table { - table.rules(tier).with_context(invalid)?; - } - } - for raw in permission.extra_roots.iter().flatten() { - extra_root(raw).with_context(invalid)?; - } + parsed.map_err(invalid_toml)?; debug!(path = %path.display(), "loaded config"); Ok(Some(table)) } @@ -388,8 +466,9 @@ mod tests { "#}, ); - let Loaded { config, origins } = - load_layers(discover(Some(dir.path()), None).unwrap()).unwrap(); + let Loaded { + config, origins, .. + } = load_layers(discover(Some(dir.path()), None).unwrap()).unwrap(); assert_eq!(config.client.model.as_deref(), Some("late-model")); assert_eq!(config.client.effort, Some(Effort::Low)); @@ -475,13 +554,105 @@ mod tests { "tui.show_thinking = true", ); - let Loaded { config, origins } = - load_layers(discover(Some(dir.path()), None).unwrap()).unwrap(); + let Loaded { + config, origins, .. + } = load_layers(discover(Some(dir.path()), None).unwrap()).unwrap(); assert_eq!(config.tui.show_thinking, Some(true)); assert_eq!(origins.into_layers(), [Layer::DropIn(drop_in)]); } + #[test] + fn names_the_file_tier_and_class_of_a_malformed_pattern() { + let dir = tempfile::tempdir().unwrap(); + let path = write( + &dir.path().join("config.toml"), + r#"permission.ask.exec = ["git push", "ls | sh"]"#, + ); + + let err = load_layers(vec![Layer::User(path.clone())]) + .err() + .expect("a malformed pattern"); + assert_eq!( + format!("{err:#}"), + format!( + "invalid config at {}: invalid permission.ask.exec pattern \"ls | sh\": \ + it must be one simple command, such as `cargo test *`. \ + Write a pattern for each command of a chain or pipeline.", + path.display() + ) + ); + } + + #[test] + fn parses_each_class_list_in_its_own_grammar() { + // Each pattern is valid in another class's grammar. + let dir = tempfile::tempdir().unwrap(); + for (tier, class, pattern) in [ + ("deny", "read", "!.env"), + ("ask", "edit", "!.env"), + ("ask", "exec", "src/*.rs"), + ("allow", "net", "github/get_me"), + ("allow", "mcp", "api.example.com"), + ] { + let path = write( + &dir.path().join("config.toml"), + &format!("permission.{tier}.{class} = [{pattern:?}]"), + ); + + let err = load_layers(vec![Layer::User(path.clone())]) + .err() + .expect(pattern); + let msg = format!("{err:#}"); + assert!( + msg.starts_with(&format!( + "invalid config at {}: \ + invalid permission.{tier}.{class} pattern {pattern:?}: ", + path.display() + )), + "{msg}" + ); + } + } + + #[test] + fn rejects_invalid_lower_entries_when_higher_layers_set_the_same_keys() { + let dir = tempfile::tempdir().unwrap(); + for (body, expected) in [ + ( + r#"permission.ask.exec = ["ls | sh"]"#, + "invalid permission.ask.exec", + ), + ( + r#"permission.extra_roots = ["shared"]"#, + "invalid extra root", + ), + (r#"permission.mode = "bypass""#, "permission.mode"), + ] { + let user = write(&dir.path().join("config.toml"), body); + write( + &dir.path().join("config.d/10-valid.toml"), + indoc! {r#" + [permission] + mode = "manual" + extra_roots = [] + ask.exec = ["git status"] + "#}, + ); + + let err = load_layers(discover(Some(dir.path()), None).unwrap()) + .err() + .expect("invalid lower entries must propagate"); + + let message = format!("{err:#}"); + assert!( + message.starts_with(&format!("invalid config at {}:", user.display())), + "{message}" + ); + assert!(message.contains(expected), "{body}: {message}"); + } + } + #[test] fn name_the_file_that_fails_the_schema() { let dir = tempfile::tempdir().unwrap(); @@ -942,56 +1113,6 @@ mod tests { ); } - #[test] - fn names_the_file_tier_and_class_of_a_malformed_pattern() { - let dir = tempfile::tempdir().unwrap(); - let path = write( - &dir.path().join("config.toml"), - r#"permission.ask.exec = ["git push", "ls | sh"]"#, - ); - - let err = read_layer(&Layer::User(path.clone())).expect_err("a malformed pattern"); - assert_eq!( - format!("{err:#}"), - format!( - "invalid config at {}: invalid permission.ask.exec pattern \"ls | sh\": \ - it must be one simple command, such as `cargo test *`. \ - Write a pattern for each command of a chain or pipeline.", - path.display() - ) - ); - } - - #[test] - fn parses_each_class_list_in_its_own_grammar() { - // Each pattern parses in another class's grammar, so the error shows that the key's own - // grammar rejected it. - let dir = tempfile::tempdir().unwrap(); - for (tier, class, pattern) in [ - ("deny", "read", "!.env"), - ("ask", "edit", "!.env"), - ("ask", "exec", "src/*.rs"), - ("allow", "net", "github/get_me"), - ("allow", "mcp", "api.example.com"), - ] { - let path = write( - &dir.path().join("config.toml"), - &format!("permission.{tier}.{class} = [{pattern:?}]"), - ); - - let err = read_layer(&Layer::User(path.clone())).expect_err(pattern); - let msg = format!("{err:#}"); - assert!( - msg.starts_with(&format!( - "invalid config at {}: \ - invalid permission.{tier}.{class} pattern {pattern:?}: ", - path.display() - )), - "{msg}" - ); - } - } - #[test] fn unreadable_path_propagates_io_error() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ox-core/src/config/schema.rs b/crates/ox-core/src/config/schema.rs index aa3f43190..ac8715d85 100644 --- a/crates/ox-core/src/config/schema.rs +++ b/crates/ox-core/src/config/schema.rs @@ -15,8 +15,7 @@ use super::StatusLineSegment; use super::compaction::ThresholdPercent; use crate::model::{Effort, PromptCacheTtl}; use crate::permission::defaults::Shipped; -use crate::permission::rule::Rule; -use crate::permission::{Mode, PermissionClass, Tier}; +use crate::permission::{Mode, PermissionClass}; use crate::util::env::whole_number_in; use crate::util::path::expand_absolute; @@ -145,8 +144,8 @@ pub(super) struct CompactionTable { pub(super) threshold_percent: Option, } -/// The tier tables hold raw patterns and `extra_roots` raw paths, which the loader parses per file, -/// so a malformed entry fails at startup. `defaults` turns shipped rule groups off by name. +/// Raw patterns and roots compile after layering, retaining each entry's file for errors. +/// `defaults` turns shipped rule groups off by name. #[derive(Debug, Default, Deserialize)] #[serde(default, deny_unknown_fields)] pub(super) struct PermissionTable { @@ -179,24 +178,6 @@ impl TierTable { (PermissionClass::Mcp, &self.mcp), ] } - - /// Parses every pattern of the `tier` table, naming the key and pattern of a malformed one. - pub(super) fn rules(&self, tier: Tier) -> Result> { - self.classes() - .into_iter() - .flat_map(|(class, patterns)| { - patterns - .iter() - .flatten() - .map(move |pattern| (class, pattern)) - }) - .map(|(class, pattern)| { - Rule::parse(class, pattern).with_context(|| { - format!("invalid permission.{tier}.{class} pattern {pattern:?}") - }) - }) - .collect() - } } #[derive(Debug, Default, Deserialize)] diff --git a/crates/ox-core/src/permission.rs b/crates/ox-core/src/permission.rs index 1f5d8054f..5aa8b3799 100644 --- a/crates/ox-core/src/permission.rs +++ b/crates/ox-core/src/permission.rs @@ -19,16 +19,14 @@ use std::sync::{Arc, LazyLock}; use anyhow::{Context as _, Result, bail, ensure}; use caseless::Caseless as _; use regex::Regex; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use strum::{Display, EnumString, IntoStaticStr, VariantArray}; use unicode_normalization::UnicodeNormalization as _; use self::defaults::{Shipped, ShippedTier}; use self::rule::{MatchDiscipline, Prefixes, Rule}; use self::shell::{CommandTarget, Segment}; -use crate::util::path::{ - expand_absolute, expand_user, lexical_normalize, requires_directory, slash_separated, -}; +use crate::util::path::{expand_absolute, lexical_normalize, requires_directory, slash_separated}; use crate::util::variant; // ── Mode ── @@ -41,7 +39,6 @@ use crate::util::variant; Default, PartialEq, Eq, - Serialize, Deserialize, Display, EnumString, @@ -92,8 +89,7 @@ impl Mode { /// The rule namespace a tool's calls fall under, which also sets the pipeline's default treatment. /// Rules address classes, so one `read` deny pattern covers every tool that reads a file. Each tool /// declares its class in its [`crate::tool::ToolSpec`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display)] -#[serde(rename_all = "lowercase")] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Display)] #[strum(serialize_all = "lowercase")] pub enum PermissionClass { /// Reads the workspace or acts only on state the session owns (`read`, `grep`, `glob`, `tasks`, @@ -112,8 +108,7 @@ pub enum PermissionClass { // ── Tier ── /// Where a rule list sits in the pipeline: deny outranks ask, which outranks allow. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display)] -#[serde(rename_all = "lowercase")] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Display)] #[strum(serialize_all = "lowercase")] pub enum Tier { Deny, @@ -138,12 +133,33 @@ pub(crate) struct Workspace { /// One anchor directory in both of its spellings. #[derive(Debug, Clone, PartialEq, Eq)] -struct Anchor { +pub(crate) struct Anchor { resolved: PathBuf, /// As configured or launched, lexically normalized. spelled: PathBuf, } +impl Anchor { + /// Resolves an absolute or `~/` extra root, excluding `/`, home, and home's ancestors. + pub(crate) fn resolve(raw: &str) -> Result { + let spelled = + expand_absolute(raw).with_context(|| format!("invalid extra root {raw:?}"))?; + let resolved = match resolve(&spelled) { + Ok(root) => root, + Err(unreachable) => bail!("invalid extra root {raw:?}: {unreachable}"), + }; + ensure!( + is_workspace(&resolved), + "invalid extra root {raw:?}: \ + `/`, the home directory, and their ancestors cannot be workspace roots" + ); + Ok(Self { + resolved, + spelled: lexical_normalize(&spelled), + }) + } +} + impl Workspace { /// The workspace launched in `cwd` with no extra roots. pub(crate) fn new(cwd: &Path) -> Self { @@ -162,18 +178,17 @@ impl Workspace { } } - /// The workspace launched in `cwd` plus the configured `extra_roots`, which config load - /// validates even when the launch directory leaves the workspace without roots. + #[cfg(test)] pub(crate) fn resolve(cwd: &Path, extra_roots: &[String]) -> Result { - let extra_roots = extra_roots + let roots = extra_roots .iter() - .map(|raw| { - Ok(Anchor { - resolved: extra_root(raw)?, - spelled: lexical_normalize(&expand_user(raw)?), - }) - }) - .collect::>>()?; + .map(|raw| Anchor::resolve(raw)) + .collect::>()?; + Ok(Self::with_roots(cwd, roots)) + } + + /// The workspace launched in `cwd`, adding validated roots only when `cwd` permits roots. + pub(crate) fn with_roots(cwd: &Path, extra_roots: Vec) -> Self { let mut workspace = Self::new(cwd); if !workspace.roots.is_empty() { for root in extra_roots { @@ -183,7 +198,7 @@ impl Workspace { } } } - Ok(workspace) + workspace } pub(crate) fn cwd(&self) -> &Path { @@ -216,22 +231,6 @@ impl Workspace { } } -/// Resolves the configured extra root `raw`, which must be absolute or `~/`-rooted, resolvable, and -/// neither `/`, `$HOME`, nor an ancestor of it. -pub(crate) fn extra_root(raw: &str) -> Result { - let root = expand_absolute(raw).with_context(|| format!("invalid extra root {raw:?}"))?; - let root = match resolve(&root) { - Ok(root) => root, - Err(unreachable) => bail!("invalid extra root {raw:?}: {unreachable}"), - }; - ensure!( - is_workspace(&root), - "invalid extra root {raw:?}: \ - `/`, the home directory, and their ancestors cannot be workspace roots" - ); - Ok(root) -} - // ── Target ── /// Whether a path target sits where an edit needs no rule. @@ -1050,7 +1049,7 @@ impl Policy { self.verdict(class, target).decision } - /// Resolves a call to a [`Verdict`] without I/O. + /// Resolves a call to a [`Verdict`], checking current deny and ask path prefixes. /// /// Order, stopping at the first match: /// 1. A deny rule denies, in every mode. @@ -1592,10 +1591,14 @@ mod tests { use super::*; #[test] - fn round_trips_through_serde_and_fromstr() { + fn parses_each_name_through_serde_and_fromstr() { for &mode in Mode::VARIANTS { let token = mode.to_string(); - assert_eq!(serde_json::to_value(mode).unwrap(), token); + assert_eq!( + serde_json::from_value::(serde_json::Value::String(token.clone())) + .unwrap(), + mode + ); assert_eq!(token.parse::().unwrap(), mode); } } diff --git a/crates/ox-core/src/permission/defaults.rs b/crates/ox-core/src/permission/defaults.rs index c056d888d..a28ee3ed2 100644 --- a/crates/ox-core/src/permission/defaults.rs +++ b/crates/ox-core/src/permission/defaults.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use anyhow::{Context as _, Result}; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use strum::{Display, VariantArray}; use super::PermissionClass; @@ -22,17 +22,7 @@ const PROCESS_ENVIRONMENTS: [&str; 2] = ["/proc/*/environ", "/proc/*/task/*/envi /// A named group of shipped rules. #[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - PartialOrd, - Ord, - Serialize, - Deserialize, - Display, - VariantArray, + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Display, VariantArray, )] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] @@ -252,6 +242,7 @@ const OX: &[&str] = &[".ox"]; #[cfg(test)] mod tests { + use std::collections::BTreeMap; use std::path::Path; use super::*; @@ -261,124 +252,99 @@ mod tests { use crate::util::path::slash_separated; use crate::util::path::testing::HOME_VAR; - /// The process-environment patterns `credentials` adds on Unix, spelled out. - const PROC_ENVIRONS: [&str; 2] = ["/proc/*/environ", "/proc/*/task/*/environ"]; - - /// Each rule of `group` as its tier, class, and pattern. - fn patterns(group: Shipped) -> Vec<(ShippedTier, PermissionClass, String)> { - group - .rules() - .unwrap() - .iter() - .map(|(tier, rule)| (*tier, rule.class(), rule.pattern().to_owned())) - .collect() - } - - /// The groups whose shipped ask rules hold an edit of `raw`, a resolved absolute path. - fn protected_by(raw: &str) -> Vec { - let target = PathTarget { - canonical: raw.to_owned(), - exact: None, - relative: None, - rooted: Vec::new(), - requested: raw.to_owned(), - deny_rooted: Vec::new(), - zone: Zone::Outside, - }; - Shipped::VARIANTS - .iter() - .copied() - .filter(|group| { - group.rules().unwrap().iter().any(|(tier, rule)| { - let bases = rule.prefix_bases(&[]); - let discipline = MatchDiscipline::Deny(Prefixes::Located(&bases)); - *tier == ShippedTier::Ask - && rule.matches_path(PermissionClass::Edit, &target, discipline) - }) - }) - .collect() - } - mod shipped { use super::*; #[test] - fn puts_each_rule_in_its_tier_and_class() { - temp_env::with_vars( - [ - (HOME_VAR, Some(abs("/home/u"))), - ("XDG_DATA_HOME", Some(abs("/data"))), - ("XDG_CONFIG_HOME", Some(abs("/config"))), - ("CLAUDE_CONFIG_DIR", None), - ], - || { - let home = slash_separated(Path::new(&abs("/home/u"))); - let data = slash_separated(Path::new(&abs("/data"))); - let config = slash_separated(Path::new(&abs("/config"))); - let ask_edit = - |pattern: String| (ShippedTier::Ask, PermissionClass::Edit, pattern); - let deny_read = - |pattern: String| (ShippedTier::Deny, PermissionClass::Read, pattern); - - let names = [".git", ".githooks", ".husky", ".gitconfig", ".gitmodules"]; - let git: Vec<_> = names - .iter() - .flat_map(|name| [format!("/**/{name}"), format!("./{name}")]) - .chain([format!("{config}/git"), format!("{home}/.git-credentials")]) - .map(ask_edit) - .collect(); - assert_eq!(patterns(Shipped::Git), git); - assert_eq!( - patterns(Shipped::Devices), - if cfg!(unix) { - vec![ask_edit("/dev/**".to_owned())] - } else { - Vec::new() - } - ); - assert_eq!(patterns(Shipped::Rm), []); - assert_eq!( - patterns(Shipped::Ssh), - [ - ask_edit(format!("{home}/.ssh")), - deny_read(format!("{home}/.ssh")) - ] - ); - let cloud = [".aws", ".azure", ".config/gcloud", ".docker", ".kube"]; - assert_eq!( - patterns(Shipped::Cloud), - cloud.map(|name| ask_edit(format!("{home}/{name}"))) - ); - let mut credentials = vec![ - format!("{data}/ox/credentials.json"), - format!("{home}/.claude/.credentials.json"), - format!("{home}/.git-credentials"), - format!("{config}/git/credentials"), - ]; - if cfg!(unix) { - credentials.extend(PROC_ENVIRONS.map(str::to_owned)); - } - assert_eq!( - patterns(Shipped::Credentials), - credentials.into_iter().map(deny_read).collect::>() - ); - }, - ); - } - - #[test] - fn names_each_group_by_its_defaults_key() { + fn reads_each_group_from_its_defaults_key() { for &group in Shipped::VARIANTS { - assert_eq!( - toml::Value::try_from(group).unwrap().as_str(), - Some(group.to_string().as_str()) - ); + let defaults: BTreeMap = + toml::from_str(&format!("{group} = false")).unwrap(); + + assert_eq!(defaults, BTreeMap::from([(group, false)])); } } mod rules { use super::*; + /// The process-environment patterns `credentials` adds on Unix, spelled out. + const PROC_ENVIRONS: [&str; 2] = ["/proc/*/environ", "/proc/*/task/*/environ"]; + + /// Each rule of `group` as its tier, class, and pattern. + fn patterns(group: Shipped) -> Vec<(ShippedTier, PermissionClass, String)> { + group + .rules() + .unwrap() + .iter() + .map(|(tier, rule)| (*tier, rule.class(), rule.pattern().to_owned())) + .collect() + } + + #[test] + fn puts_each_rule_in_its_tier_and_class() { + temp_env::with_vars( + [ + (HOME_VAR, Some(abs("/home/u"))), + ("XDG_DATA_HOME", Some(abs("/data"))), + ("XDG_CONFIG_HOME", Some(abs("/config"))), + ("CLAUDE_CONFIG_DIR", None), + ], + || { + let home = slash_separated(Path::new(&abs("/home/u"))); + let data = slash_separated(Path::new(&abs("/data"))); + let config = slash_separated(Path::new(&abs("/config"))); + let ask_edit = + |pattern: String| (ShippedTier::Ask, PermissionClass::Edit, pattern); + let deny_read = + |pattern: String| (ShippedTier::Deny, PermissionClass::Read, pattern); + + let names = [".git", ".githooks", ".husky", ".gitconfig", ".gitmodules"]; + let git: Vec<_> = names + .iter() + .flat_map(|name| [format!("/**/{name}"), format!("./{name}")]) + .chain([format!("{config}/git"), format!("{home}/.git-credentials")]) + .map(ask_edit) + .collect(); + assert_eq!(patterns(Shipped::Git), git); + assert_eq!( + patterns(Shipped::Devices), + if cfg!(unix) { + vec![ask_edit("/dev/**".to_owned())] + } else { + Vec::new() + } + ); + assert_eq!(patterns(Shipped::Rm), []); + assert_eq!( + patterns(Shipped::Ssh), + [ + ask_edit(format!("{home}/.ssh")), + deny_read(format!("{home}/.ssh")) + ] + ); + let cloud = [".aws", ".azure", ".config/gcloud", ".docker", ".kube"]; + assert_eq!( + patterns(Shipped::Cloud), + cloud.map(|name| ask_edit(format!("{home}/{name}"))) + ); + let mut credentials = vec![ + format!("{data}/ox/credentials.json"), + format!("{home}/.claude/.credentials.json"), + format!("{home}/.git-credentials"), + format!("{config}/git/credentials"), + ]; + if cfg!(unix) { + credentials.extend(PROC_ENVIRONS.map(str::to_owned)); + } + assert_eq!( + patterns(Shipped::Credentials), + credentials.into_iter().map(deny_read).collect::>() + ); + }, + ); + } + #[test] fn name_the_home_directory_however_its_variable_spells_it() { let home = abs("/home/u"); @@ -439,6 +405,31 @@ mod tests { mod protected_names { use super::*; + /// The groups whose shipped ask rules hold an edit of `raw`, a resolved absolute path. + fn protected_by(raw: &str) -> Vec { + let target = PathTarget { + canonical: raw.to_owned(), + exact: None, + relative: None, + rooted: Vec::new(), + requested: raw.to_owned(), + deny_rooted: Vec::new(), + zone: Zone::Outside, + }; + Shipped::VARIANTS + .iter() + .copied() + .filter(|group| { + group.rules().unwrap().iter().any(|(tier, rule)| { + let bases = rule.prefix_bases(&[]); + let discipline = MatchDiscipline::Deny(Prefixes::Located(&bases)); + *tier == ShippedTier::Ask + && rule.matches_path(PermissionClass::Edit, &target, discipline) + }) + }) + .collect() + } + #[test] fn name_the_group_of_each_protected_name_at_any_depth_and_spelling() { for (raw, group) in [ diff --git a/crates/ox-core/src/permission/shell/reader.rs b/crates/ox-core/src/permission/shell/reader.rs index 9ac992f9b..3d1db8a69 100644 --- a/crates/ox-core/src/permission/shell/reader.rs +++ b/crates/ox-core/src/permission/shell/reader.rs @@ -1733,6 +1733,14 @@ mod tests { mod reader_args { use super::*; + /// The files `reader` reads from the literal words of `args`. + fn files(reader: &str, args: &str) -> Vec { + let values: Vec> = args.split(' ').map(Some).collect(); + let read = reader_args(reader, &values); + assert!(!read.unknown, "{reader} {args}"); + read.files.into_iter().map(|(_, file)| file.path).collect() + } + // cspell:ignore drecurse rfpatterns #[test] fn distinguishes_recursive_operands_from_exact_auxiliary_files() { @@ -1793,14 +1801,6 @@ mod tests { } } - /// The files `reader` reads from the literal words of `args`. - fn files(reader: &str, args: &str) -> Vec { - let values: Vec> = args.split(' ').map(Some).collect(); - let read = reader_args(reader, &values); - assert!(!read.unknown, "{reader} {args}"); - read.files.into_iter().map(|(_, file)| file.path).collect() - } - #[test] fn reads_option_values_as_values() { for (reader, args) in [ diff --git a/docs/design/permissions.md b/docs/design/permissions.md index 715767e4b..e587b76fa 100644 --- a/docs/design/permissions.md +++ b/docs/design/permissions.md @@ -131,7 +131,7 @@ The `--no-tui` REPL and `-p` headless runs share `run_op`, which passes each `Ev ## Sources - `crates/ox-core/src/config.rs`: `resolve_permission`, `Config::set_unattended`, and `OX_PERMISSION_MODE`. -- `crates/ox-core/src/config/load.rs`: `reject_project_keys` and the project allowlists. +- `crates/ox-core/src/config/load.rs`: `ParsedPermission`, `reject_project_keys`, and the project allowlists. - `crates/ox-core/src/engine.rs`: the live policy, `Engine::set_mode`, and `Engine::serve`. - `crates/ox-core/src/engine/abort.rs`: `Mailbox`, which keeps the mode switches and queued prompts that arrive mid-operation. - `crates/ox-core/src/engine/dispatch.rs`: `run_tool_round`, `Dispatcher::gate`, `check_permission`, `allow_for_session`, and `await_approval`.