Skip to main content

gloves_config/
config.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    fs,
4    path::{Component, Path, PathBuf},
5};
6
7#[cfg(unix)]
8use std::os::unix::fs::PermissionsExt;
9
10use serde::{Deserialize, Serialize};
11
12use gloves_core::error::{GlovesError, Result};
13use gloves_core::types::{AgentId, SecretId};
14
15const CONFIG_VERSION_V1: u32 = 1;
16const CONFIG_VERSION_V2: u32 = 2;
17const DEFAULT_ROOT: &str = ".openclaw/secrets";
18const DEFAULT_DAEMON_BIND: &str = "127.0.0.1:7788";
19const DEFAULT_DAEMON_IO_TIMEOUT_SECONDS: u64 = 5;
20const DEFAULT_DAEMON_REQUEST_LIMIT_BYTES: usize = 16 * 1024;
21const DEFAULT_AGENT_ID: &str = "default-agent";
22/// Built-in default secret and request TTL in days when config does not override it.
23pub const DEFAULT_SECRET_TTL_DAYS: i64 = 30;
24const DEFAULT_VAULT_MOUNT_TTL: &str = "1h";
25const DEFAULT_VAULT_SECRET_TTL_DAYS: i64 = 365;
26const DEFAULT_VAULT_SECRET_LENGTH_BYTES: usize = 64;
27const URL_SCHEME_HTTP_PREFIX: &str = "http://";
28const URL_SCHEME_HTTPS_PREFIX: &str = "https://";
29
30/// Default bootstrap config file name.
31pub const CONFIG_FILE_NAME: &str = ".gloves.toml";
32/// Supported bootstrap config schema version.
33pub const CONFIG_SCHEMA_VERSION: u32 = CONFIG_VERSION_V2;
34
35/// Source used to select the effective config file.
36#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
37pub enum ConfigSource {
38    /// Selected via `--config` CLI flag.
39    Flag,
40    /// Selected via `GLOVES_CONFIG` environment variable.
41    Env,
42    /// Selected by walking from the current working directory to root.
43    Discovered,
44    /// No config file selected.
45    None,
46}
47
48/// Resolved config selection before parsing.
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50pub struct ConfigSelection {
51    /// Source used for selection.
52    pub source: ConfigSource,
53    /// Selected path when a config file was found.
54    pub path: Option<PathBuf>,
55}
56
57/// Allowed operations for one agent's private-path visibility.
58#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
59#[serde(rename_all = "lowercase")]
60pub enum PathOperation {
61    /// Read file contents.
62    Read,
63    /// Write or modify files.
64    Write,
65    /// List directory entries.
66    List,
67    /// Mount encrypted volumes.
68    Mount,
69}
70
71/// Allowed operations for one agent's secret ACL.
72#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
73#[serde(rename_all = "lowercase")]
74pub enum SecretAclOperation {
75    /// Read secret values.
76    Read,
77    /// Create/update secrets.
78    Write,
79    /// List visible secrets.
80    List,
81    /// Revoke secrets.
82    Revoke,
83    /// Create human access requests.
84    Request,
85    /// Read request status for a secret.
86    Status,
87    /// Approve pending requests.
88    Approve,
89    /// Deny pending requests.
90    Deny,
91}
92
93/// Runtime mode for vault command availability and dependency enforcement.
94#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
95#[serde(rename_all = "lowercase")]
96pub enum VaultMode {
97    /// Vault commands run when dependencies are available.
98    Auto,
99    /// Vault dependencies are mandatory and validated up front.
100    Required,
101    /// Vault commands are blocked intentionally.
102    Disabled,
103}
104
105/// Raw TOML shape for one `.gloves.toml` file.
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
107#[serde(deny_unknown_fields)]
108pub struct GlovesConfigFile {
109    /// Schema version.
110    pub version: u32,
111    /// Optional path overrides.
112    #[serde(default)]
113    pub paths: ConfigPathsFile,
114    /// Private path aliases and values.
115    #[serde(default)]
116    pub private_paths: BTreeMap<String, String>,
117    /// Daemon defaults.
118    #[serde(default)]
119    pub daemon: DaemonConfigFile,
120    /// Vault runtime mode defaults.
121    #[serde(default)]
122    pub vault: VaultConfigFile,
123    /// Global defaults.
124    #[serde(default)]
125    pub defaults: DefaultsConfigFile,
126    /// Integration declarations.
127    #[serde(default)]
128    pub integrations: BTreeMap<String, IntegrationConfigFile>,
129    /// Agent path visibility policies.
130    #[serde(default)]
131    pub agents: BTreeMap<String, AgentAccessFile>,
132    /// Secret ACL policies.
133    #[serde(default)]
134    pub secrets: SecretsConfigFile,
135}
136
137/// Raw `[paths]` section from TOML.
138#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
139#[serde(deny_unknown_fields)]
140pub struct ConfigPathsFile {
141    /// Runtime root override.
142    pub root: Option<String>,
143}
144
145/// Raw `[daemon]` section from TOML.
146#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
147#[serde(deny_unknown_fields)]
148pub struct DaemonConfigFile {
149    /// Bind address for daemon mode.
150    pub bind: Option<String>,
151    /// Read/write timeout in seconds.
152    pub io_timeout_seconds: Option<u64>,
153    /// Maximum request size in bytes.
154    pub request_limit_bytes: Option<usize>,
155}
156
157/// Raw `[vault]` section from TOML.
158#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
159#[serde(deny_unknown_fields)]
160pub struct VaultConfigFile {
161    /// Vault runtime mode.
162    pub mode: Option<VaultMode>,
163    /// Named vault mount locations.
164    #[serde(default)]
165    pub mounts: BTreeMap<String, String>,
166}
167
168/// Raw `[defaults]` section from TOML.
169#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
170#[serde(deny_unknown_fields)]
171pub struct DefaultsConfigFile {
172    /// Default agent identifier.
173    pub agent_id: Option<String>,
174    /// Default secret TTL in days.
175    pub secret_ttl_days: Option<i64>,
176    /// Default vault mount TTL literal.
177    pub vault_mount_ttl: Option<String>,
178    /// Default vault secret TTL in days.
179    pub vault_secret_ttl_days: Option<i64>,
180    /// Default generated vault secret length in bytes.
181    pub vault_secret_length_bytes: Option<usize>,
182}
183
184/// Raw `[secrets]` section from TOML.
185#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
186#[serde(deny_unknown_fields)]
187pub struct SecretsConfigFile {
188    /// Per-agent ACL rules for secret operations.
189    #[serde(default)]
190    pub acl: BTreeMap<String, SecretAccessFile>,
191    /// Per-command pipe safety policies.
192    #[serde(default)]
193    pub pipe: SecretPipePoliciesFile,
194}
195
196/// Raw per-agent secret ACL from TOML.
197#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
198#[serde(deny_unknown_fields)]
199pub struct SecretAccessFile {
200    /// Secret ref patterns (`*`, `foo/*`, or exact secret id).
201    #[serde(default, alias = "paths")]
202    pub refs: Vec<String>,
203    /// Allowed secret operations.
204    #[serde(default)]
205    pub operations: Vec<SecretAclOperation>,
206}
207
208/// Raw per-command pipe policy set from TOML.
209#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
210#[serde(deny_unknown_fields)]
211pub struct SecretPipePoliciesFile {
212    /// Command policy entries keyed by executable name.
213    #[serde(default)]
214    pub commands: BTreeMap<String, SecretPipeCommandPolicyFile>,
215}
216
217/// Raw pipe policy for one command from TOML.
218#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
219#[serde(deny_unknown_fields)]
220pub struct SecretPipeCommandPolicyFile {
221    /// Require at least one URL argument and enforce allowed URL prefixes.
222    #[serde(default)]
223    pub require_url: bool,
224    /// Allowed URL prefixes for this command.
225    #[serde(default)]
226    pub url_prefixes: Vec<String>,
227}
228
229/// Raw per-agent access policy from TOML.
230#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
231#[serde(deny_unknown_fields)]
232pub struct AgentAccessFile {
233    /// Alias names from `[private_paths]` visible to this agent.
234    #[serde(default)]
235    pub paths: Vec<String>,
236    /// Allowed operations.
237    #[serde(default)]
238    pub operations: Vec<PathOperation>,
239    /// Secret ref access policy for this agent.
240    #[serde(default)]
241    pub secrets: Option<AgentSecretsAccessFile>,
242    /// Vault mount access policy for this agent.
243    #[serde(default)]
244    pub vault: Option<AgentVaultAccessFile>,
245}
246
247/// Raw per-agent secret access policy from TOML.
248#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
249#[serde(deny_unknown_fields)]
250pub struct AgentSecretsAccessFile {
251    /// Secret ref patterns (`*`, `foo/*`, or exact secret id).
252    #[serde(default, alias = "paths")]
253    pub refs: Vec<String>,
254    /// Allowed secret operations.
255    #[serde(default)]
256    pub operations: Vec<SecretAclOperation>,
257}
258
259/// Raw per-agent vault access policy from TOML.
260#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
261#[serde(deny_unknown_fields)]
262pub struct AgentVaultAccessFile {
263    /// Named vault mounts visible to this agent.
264    #[serde(default)]
265    pub mounts: Vec<String>,
266    /// Allowed mount operations.
267    #[serde(default)]
268    pub operations: Vec<PathOperation>,
269}
270
271/// Raw integration entry from TOML.
272#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
273#[serde(deny_unknown_fields)]
274pub struct IntegrationConfigFile {
275    /// Owning or default operator agent for this integration.
276    pub agent: Option<String>,
277    /// Optional account/profile names. Omitted implies `default`.
278    #[serde(default)]
279    pub profiles: Vec<String>,
280    /// Optional secret slots inferred under each profile.
281    #[serde(default)]
282    pub slots: Vec<String>,
283}
284
285/// Effective daemon config after defaults and validation.
286#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
287pub struct DaemonBootstrapConfig {
288    /// Bind address for daemon mode.
289    pub bind: String,
290    /// Read/write timeout in seconds.
291    pub io_timeout_seconds: u64,
292    /// Maximum request size in bytes.
293    pub request_limit_bytes: usize,
294}
295
296/// Effective vault mode after defaults and validation.
297#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
298pub struct VaultBootstrapConfig {
299    /// Effective vault runtime mode.
300    pub mode: VaultMode,
301    /// Named vault mount locations.
302    pub mounts: BTreeMap<String, PathBuf>,
303}
304
305/// Effective default values after defaults and validation.
306#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
307pub struct DefaultBootstrapConfig {
308    /// Default agent identifier.
309    pub agent_id: AgentId,
310    /// Default secret TTL in days.
311    pub secret_ttl_days: i64,
312    /// Default vault mount TTL literal.
313    pub vault_mount_ttl: String,
314    /// Default vault secret TTL in days.
315    pub vault_secret_ttl_days: i64,
316    /// Default generated vault secret length in bytes.
317    pub vault_secret_length_bytes: usize,
318}
319
320/// Effective access policy for one configured agent.
321#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
322pub struct AgentAccessPolicy {
323    /// Alias names from `[private_paths]` visible to this agent.
324    pub path_aliases: Vec<String>,
325    /// Allowed operations.
326    pub operations: Vec<PathOperation>,
327}
328
329/// Effective secret ACL policy for one configured agent.
330#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
331pub struct SecretAccessPolicy {
332    /// Secret ref patterns (`*`, `foo/*`, or exact secret id).
333    pub refs: Vec<String>,
334    /// Allowed secret operations.
335    pub operations: Vec<SecretAclOperation>,
336}
337
338/// Effective vault access policy for one configured agent.
339#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
340pub struct AgentVaultAccessPolicy {
341    /// Named mounts visible to this agent.
342    pub mount_names: Vec<String>,
343    /// Allowed operations.
344    pub operations: Vec<PathOperation>,
345}
346
347/// Effective integration config after defaults and validation.
348#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
349pub struct IntegrationConfig {
350    /// Integration identifier.
351    pub name: String,
352    /// Owning or default agent for this integration.
353    pub agent: AgentId,
354    /// Declared profiles. Empty means `default`.
355    pub profiles: Vec<String>,
356    /// Declared secret slots under each profile.
357    pub slots: Vec<String>,
358}
359
360/// Effective pipe policy for one command.
361#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
362pub struct SecretPipeCommandPolicy {
363    /// Require URL enforcement for this command.
364    pub require_url: bool,
365    /// Allowed URL prefixes.
366    pub url_prefixes: Vec<String>,
367}
368
369/// Effective and validated `.gloves.toml` configuration.
370#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
371pub struct GlovesConfig {
372    /// Absolute config file path.
373    pub source_path: PathBuf,
374    /// Effective runtime root.
375    pub root: PathBuf,
376    /// Private path aliases resolved to absolute paths.
377    pub private_paths: BTreeMap<String, PathBuf>,
378    /// Effective daemon defaults.
379    pub daemon: DaemonBootstrapConfig,
380    /// Effective vault mode.
381    pub vault: VaultBootstrapConfig,
382    /// Effective global defaults.
383    pub defaults: DefaultBootstrapConfig,
384    /// Agent access policies.
385    pub agents: BTreeMap<String, AgentAccessPolicy>,
386    /// Agent secret ACL policies.
387    pub secret_access: BTreeMap<String, SecretAccessPolicy>,
388    /// Agent vault access policies.
389    pub agent_vault_access: BTreeMap<String, AgentVaultAccessPolicy>,
390    /// Configured integrations.
391    pub integrations: BTreeMap<String, IntegrationConfig>,
392    /// Per-command secret pipe policies.
393    pub secret_pipe_commands: BTreeMap<String, SecretPipeCommandPolicy>,
394}
395
396/// Resolved path visibility entry for one agent.
397#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
398pub struct ResolvedAgentPathAccess {
399    /// Alias from `[private_paths]`.
400    pub alias: String,
401    /// Resolved path.
402    pub path: PathBuf,
403    /// Allowed operations for this agent.
404    pub operations: Vec<PathOperation>,
405}
406
407impl GlovesConfig {
408    /// Loads and validates a config file from disk.
409    pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self> {
410        let cwd = std::env::current_dir()?;
411        let absolute_path = absolutize_path(path.as_ref(), &cwd);
412        if !absolute_path.exists() {
413            return Err(GlovesError::InvalidInput(format!(
414                "config file does not exist: {}",
415                absolute_path.display()
416            )));
417        }
418
419        let raw = fs::read_to_string(&absolute_path)?;
420        let parsed = toml::from_str::<GlovesConfigFile>(&raw)
421            .map_err(|error| GlovesError::InvalidInput(format!("invalid config TOML: {error}")))?;
422        validate_config_file_permissions(&absolute_path, !parsed.private_paths.is_empty())?;
423        build_config(parsed, &absolute_path)
424    }
425
426    /// Parses and validates config from TOML text.
427    pub fn parse_from_str(raw: &str, source_path: impl AsRef<Path>) -> Result<Self> {
428        let parsed = toml::from_str::<GlovesConfigFile>(raw)
429            .map_err(|error| GlovesError::InvalidInput(format!("invalid config TOML: {error}")))?;
430        build_config(parsed, source_path.as_ref())
431    }
432
433    /// Returns resolved private-path visibility for one agent.
434    pub fn agent_paths(&self, agent: &AgentId) -> Result<Vec<ResolvedAgentPathAccess>> {
435        let policy = self
436            .agents
437            .get(agent.as_str())
438            .ok_or(GlovesError::NotFound)?;
439
440        let mut entries = Vec::with_capacity(policy.path_aliases.len());
441        for alias in &policy.path_aliases {
442            let path = self.private_paths.get(alias).ok_or_else(|| {
443                GlovesError::InvalidInput(format!(
444                    "agent '{}' references unknown private path alias '{}'",
445                    agent.as_str(),
446                    alias
447                ))
448            })?;
449            entries.push(ResolvedAgentPathAccess {
450                alias: alias.clone(),
451                path: path.clone(),
452                operations: policy.operations.clone(),
453            });
454        }
455        Ok(entries)
456    }
457
458    /// Returns `true` when the config enables per-agent secret ACLs.
459    pub fn has_secret_acl(&self) -> bool {
460        !self.secret_access.is_empty()
461    }
462
463    /// Returns secret ACL policy for one agent.
464    pub fn secret_access_policy(&self, agent: &AgentId) -> Option<&SecretAccessPolicy> {
465        self.secret_access.get(agent.as_str())
466    }
467
468    /// Returns vault access policy for one agent.
469    pub fn agent_vault_access_policy(&self, agent: &AgentId) -> Option<&AgentVaultAccessPolicy> {
470        self.agent_vault_access.get(agent.as_str())
471    }
472
473    /// Returns one configured vault mount path.
474    pub fn vault_mount_path(&self, mount_name: &str) -> Option<&PathBuf> {
475        self.vault.mounts.get(mount_name)
476    }
477
478    /// Returns one configured integration.
479    pub fn integration(&self, name: &str) -> Option<&IntegrationConfig> {
480        self.integrations.get(name)
481    }
482
483    /// Returns inferred secret refs for one configured integration.
484    pub fn inferred_integration_refs(&self, name: &str) -> Result<Vec<String>> {
485        let integration = self.integrations.get(name).ok_or(GlovesError::NotFound)?;
486        let profiles = if integration.profiles.is_empty() {
487            vec!["default".to_owned()]
488        } else {
489            integration.profiles.clone()
490        };
491        if integration.slots.is_empty() {
492            return Ok(Vec::new());
493        }
494
495        let mut refs = Vec::with_capacity(profiles.len() * integration.slots.len());
496        for profile in profiles {
497            for slot in &integration.slots {
498                refs.push(format!("{name}/{profile}/{slot}"));
499            }
500        }
501        Ok(refs)
502    }
503
504    /// Returns secret pipe policy for one executable command.
505    pub fn secret_pipe_command_policy(&self, command: &str) -> Option<&SecretPipeCommandPolicy> {
506        self.secret_pipe_commands.get(command)
507    }
508}
509
510impl SecretAccessPolicy {
511    /// Returns `true` when this policy allows an operation.
512    pub fn allows_operation(&self, operation: SecretAclOperation) -> bool {
513        self.operations.contains(&operation)
514    }
515
516    /// Returns `true` when this policy allows one secret name.
517    pub fn allows_secret(&self, secret_name: &str) -> bool {
518        self.refs
519            .iter()
520            .any(|pattern| secret_pattern_matches(pattern, secret_name))
521    }
522}
523
524/// Resolves one config path based on precedence rules.
525pub fn resolve_config_path(
526    explicit_path: Option<&Path>,
527    env_path: Option<&str>,
528    no_config: bool,
529    cwd: impl AsRef<Path>,
530) -> Result<ConfigSelection> {
531    if no_config {
532        return Ok(ConfigSelection {
533            source: ConfigSource::None,
534            path: None,
535        });
536    }
537
538    let cwd = cwd.as_ref();
539    if let Some(path) = explicit_path {
540        let candidate = absolutize_path(path, cwd);
541        if !is_regular_config_candidate(&candidate) {
542            return Err(GlovesError::InvalidInput(format!(
543                "config file must be a regular file: {}",
544                candidate.display()
545            )));
546        }
547        return Ok(ConfigSelection {
548            source: ConfigSource::Flag,
549            path: Some(candidate),
550        });
551    }
552
553    if let Some(value) = env_path {
554        if value.trim().is_empty() {
555            return Err(GlovesError::InvalidInput(
556                "GLOVES_CONFIG cannot be empty".to_owned(),
557            ));
558        }
559
560        let candidate = absolutize_path(Path::new(value), cwd);
561        if !is_regular_config_candidate(&candidate) {
562            return Err(GlovesError::InvalidInput(format!(
563                "config file must be a regular file: {}",
564                candidate.display()
565            )));
566        }
567        return Ok(ConfigSelection {
568            source: ConfigSource::Env,
569            path: Some(candidate),
570        });
571    }
572
573    if let Some(discovered) = discover_config(cwd) {
574        return Ok(ConfigSelection {
575            source: ConfigSource::Discovered,
576            path: Some(discovered),
577        });
578    }
579
580    Ok(ConfigSelection {
581        source: ConfigSource::None,
582        path: None,
583    })
584}
585
586/// Discovers `.gloves.toml` by walking from `start_dir` to filesystem root.
587pub fn discover_config(start_dir: impl AsRef<Path>) -> Option<PathBuf> {
588    let mut current = start_dir.as_ref();
589    loop {
590        let candidate = current.join(CONFIG_FILE_NAME);
591        if is_regular_config_candidate(&candidate) {
592            return Some(candidate);
593        }
594
595        let parent = current.parent()?;
596        current = parent;
597    }
598}
599
600fn build_config(raw: GlovesConfigFile, source_path: &Path) -> Result<GlovesConfig> {
601    validate_raw_config(&raw)?;
602
603    let source_path = absolutize_path(source_path, &std::env::current_dir()?);
604    let source_dir = source_path.parent().unwrap_or(Path::new("."));
605
606    let root_literal = raw.paths.root.as_deref().unwrap_or(DEFAULT_ROOT).to_owned();
607    let root = resolve_path_value(&root_literal, source_dir)?;
608
609    let mut private_paths = BTreeMap::new();
610    for (alias, value) in &raw.private_paths {
611        validate_alias(alias)?;
612        let resolved = resolve_path_value(value, source_dir)?;
613        private_paths.insert(alias.clone(), resolved);
614    }
615
616    let daemon = resolve_daemon_config(&raw.daemon)?;
617    let vault = resolve_vault_config(&raw.vault, source_dir)?;
618    let defaults = resolve_default_config(&raw.defaults)?;
619
620    let mut agents = BTreeMap::new();
621    let mut agent_vault_access = BTreeMap::new();
622    for (agent_name, policy) in &raw.agents {
623        AgentId::new(agent_name)?;
624        validate_agent_policy(agent_name, policy, &private_paths, &vault.mounts)?;
625        if !policy.paths.is_empty() || !policy.operations.is_empty() {
626            agents.insert(
627                agent_name.clone(),
628                AgentAccessPolicy {
629                    path_aliases: policy.paths.clone(),
630                    operations: policy.operations.clone(),
631                },
632            );
633        }
634        if let Some(vault_policy) = policy.vault.as_ref() {
635            agent_vault_access.insert(
636                agent_name.clone(),
637                AgentVaultAccessPolicy {
638                    mount_names: vault_policy.mounts.clone(),
639                    operations: vault_policy.operations.clone(),
640                },
641            );
642        }
643    }
644
645    let mut secret_access: BTreeMap<String, SecretAccessPolicy> = BTreeMap::new();
646    for (agent_name, policy) in &raw.secrets.acl {
647        AgentId::new(agent_name)?;
648        validate_secret_access_policy(agent_name, policy)?;
649        merge_secret_access_policy(
650            &mut secret_access,
651            agent_name,
652            SecretAccessPolicy {
653                refs: policy.refs.clone(),
654                operations: policy.operations.clone(),
655            },
656        );
657    }
658    for (agent_name, policy) in &raw.agents {
659        if let Some(secret_policy) = policy.secrets.as_ref() {
660            merge_secret_access_policy(
661                &mut secret_access,
662                agent_name,
663                SecretAccessPolicy {
664                    refs: secret_policy.refs.clone(),
665                    operations: secret_policy.operations.clone(),
666                },
667            );
668        }
669    }
670
671    let mut integrations = BTreeMap::new();
672    for (name, integration) in &raw.integrations {
673        validate_integration_config(name, integration)?;
674        let agent_literal = integration.agent.as_deref().ok_or_else(|| {
675            GlovesError::InvalidInput(format!("integration '{name}' must declare an owning agent"))
676        })?;
677        integrations.insert(
678            name.clone(),
679            IntegrationConfig {
680                name: name.clone(),
681                agent: AgentId::new(agent_literal)?,
682                profiles: normalized_integration_segments(&integration.profiles, "profiles", name)?,
683                slots: normalized_integration_segments(&integration.slots, "slots", name)?,
684            },
685        );
686    }
687
688    let mut secret_pipe_commands = BTreeMap::new();
689    for (command, policy) in &raw.secrets.pipe.commands {
690        validate_secret_pipe_command_policy(command, policy)?;
691        secret_pipe_commands.insert(
692            command.clone(),
693            SecretPipeCommandPolicy {
694                require_url: policy.require_url,
695                url_prefixes: policy.url_prefixes.clone(),
696            },
697        );
698    }
699
700    Ok(GlovesConfig {
701        source_path,
702        root,
703        private_paths,
704        daemon,
705        vault,
706        defaults,
707        agents,
708        secret_access,
709        agent_vault_access,
710        integrations,
711        secret_pipe_commands,
712    })
713}
714
715fn validate_raw_config(config: &GlovesConfigFile) -> Result<()> {
716    if !matches!(config.version, CONFIG_VERSION_V1 | CONFIG_VERSION_V2) {
717        return Err(GlovesError::InvalidInput(format!(
718            "unsupported config version {} (expected {} or {})",
719            config.version, CONFIG_VERSION_V1, CONFIG_VERSION_V2
720        )));
721    }
722
723    if let Some(root) = config.paths.root.as_ref() {
724        validate_path_literal(root, "paths.root")?;
725    }
726
727    for (alias, value) in &config.private_paths {
728        validate_alias(alias)?;
729        validate_path_literal(value, &format!("private_paths.{alias}"))?;
730    }
731
732    let _ = resolve_daemon_config(&config.daemon)?;
733    let _ = resolve_vault_config(&config.vault, Path::new("."))?;
734    let _ = resolve_default_config(&config.defaults)?;
735
736    for (agent_name, policy) in &config.secrets.acl {
737        AgentId::new(agent_name)?;
738        validate_secret_access_policy(agent_name, policy)?;
739    }
740    for (agent_name, policy) in &config.agents {
741        AgentId::new(agent_name)?;
742        validate_agent_policy(
743            agent_name,
744            policy,
745            &BTreeMap::<String, PathBuf>::new(),
746            &config.vault.mounts,
747        )?;
748    }
749    for (name, integration) in &config.integrations {
750        validate_integration_config(name, integration)?;
751    }
752    for (command, policy) in &config.secrets.pipe.commands {
753        validate_secret_pipe_command_policy(command, policy)?;
754    }
755
756    Ok(())
757}
758
759fn resolve_vault_config(raw: &VaultConfigFile, source_dir: &Path) -> Result<VaultBootstrapConfig> {
760    let mut mounts = BTreeMap::new();
761    for (mount_name, mount_path_literal) in &raw.mounts {
762        validate_alias(mount_name)?;
763        validate_path_literal(mount_path_literal, &format!("vault.mounts.{mount_name}"))?;
764        mounts.insert(
765            mount_name.clone(),
766            resolve_path_value(mount_path_literal, source_dir)?,
767        );
768    }
769
770    Ok(VaultBootstrapConfig {
771        mode: raw.mode.unwrap_or(VaultMode::Auto),
772        mounts,
773    })
774}
775
776fn resolve_daemon_config(raw: &DaemonConfigFile) -> Result<DaemonBootstrapConfig> {
777    let bind = raw
778        .bind
779        .clone()
780        .unwrap_or_else(|| DEFAULT_DAEMON_BIND.to_owned());
781    let bind_addr = bind.parse::<std::net::SocketAddr>().map_err(|error| {
782        GlovesError::InvalidInput(format!("invalid daemon bind address: {error}"))
783    })?;
784    if bind_addr.port() == 0 {
785        return Err(GlovesError::InvalidInput(
786            "daemon bind port must be non-zero".to_owned(),
787        ));
788    }
789    if !bind_addr.ip().is_loopback() {
790        return Err(GlovesError::InvalidInput(
791            "daemon bind address must be loopback".to_owned(),
792        ));
793    }
794
795    let io_timeout_seconds = raw
796        .io_timeout_seconds
797        .unwrap_or(DEFAULT_DAEMON_IO_TIMEOUT_SECONDS);
798    if io_timeout_seconds == 0 {
799        return Err(GlovesError::InvalidInput(
800            "daemon io_timeout_seconds must be greater than zero".to_owned(),
801        ));
802    }
803
804    let request_limit_bytes = raw
805        .request_limit_bytes
806        .unwrap_or(DEFAULT_DAEMON_REQUEST_LIMIT_BYTES);
807    if request_limit_bytes == 0 {
808        return Err(GlovesError::InvalidInput(
809            "daemon request_limit_bytes must be greater than zero".to_owned(),
810        ));
811    }
812
813    Ok(DaemonBootstrapConfig {
814        bind,
815        io_timeout_seconds,
816        request_limit_bytes,
817    })
818}
819
820fn merge_secret_access_policy(
821    policies: &mut BTreeMap<String, SecretAccessPolicy>,
822    agent_name: &str,
823    next_policy: SecretAccessPolicy,
824) {
825    let entry = policies
826        .entry(agent_name.to_owned())
827        .or_insert_with(|| SecretAccessPolicy {
828            refs: Vec::new(),
829            operations: Vec::new(),
830        });
831    entry.refs.extend(next_policy.refs);
832    entry.operations.extend(next_policy.operations);
833    dedup_preserving_order(&mut entry.refs);
834    dedup_preserving_order(&mut entry.operations);
835}
836
837fn dedup_preserving_order<T>(values: &mut Vec<T>)
838where
839    T: Clone + Ord,
840{
841    let mut seen = BTreeSet::new();
842    values.retain(|value| seen.insert(value.clone()));
843}
844
845fn resolve_default_config(raw: &DefaultsConfigFile) -> Result<DefaultBootstrapConfig> {
846    let agent_literal = raw
847        .agent_id
848        .as_deref()
849        .unwrap_or(DEFAULT_AGENT_ID)
850        .to_owned();
851    let agent_id = AgentId::new(&agent_literal)?;
852
853    let secret_ttl_days = raw.secret_ttl_days.unwrap_or(DEFAULT_SECRET_TTL_DAYS);
854    if secret_ttl_days <= 0 {
855        return Err(GlovesError::InvalidInput(
856            "defaults.secret_ttl_days must be greater than zero".to_owned(),
857        ));
858    }
859
860    let vault_mount_ttl = raw
861        .vault_mount_ttl
862        .as_deref()
863        .unwrap_or(DEFAULT_VAULT_MOUNT_TTL)
864        .to_owned();
865    validate_duration_literal(&vault_mount_ttl, "defaults.vault_mount_ttl")?;
866
867    let vault_secret_ttl_days = raw
868        .vault_secret_ttl_days
869        .unwrap_or(DEFAULT_VAULT_SECRET_TTL_DAYS);
870    if vault_secret_ttl_days <= 0 {
871        return Err(GlovesError::InvalidInput(
872            "defaults.vault_secret_ttl_days must be greater than zero".to_owned(),
873        ));
874    }
875
876    let vault_secret_length_bytes = raw
877        .vault_secret_length_bytes
878        .unwrap_or(DEFAULT_VAULT_SECRET_LENGTH_BYTES);
879    if vault_secret_length_bytes == 0 {
880        return Err(GlovesError::InvalidInput(
881            "defaults.vault_secret_length_bytes must be greater than zero".to_owned(),
882        ));
883    }
884
885    Ok(DefaultBootstrapConfig {
886        agent_id,
887        secret_ttl_days,
888        vault_mount_ttl,
889        vault_secret_ttl_days,
890        vault_secret_length_bytes,
891    })
892}
893
894fn validate_agent_policy<PrivatePathValue, VaultMountValue>(
895    agent_name: &str,
896    policy: &AgentAccessFile,
897    private_paths: &BTreeMap<String, PrivatePathValue>,
898    vault_mounts: &BTreeMap<String, VaultMountValue>,
899) -> Result<()> {
900    let has_legacy_path_policy = !policy.paths.is_empty() || !policy.operations.is_empty();
901    let has_secret_policy = policy.secrets.is_some();
902    let has_vault_policy = policy.vault.is_some();
903
904    if !has_legacy_path_policy && !has_secret_policy && !has_vault_policy {
905        return Err(GlovesError::InvalidInput(format!(
906            "agent '{agent_name}' must include at least one access policy"
907        )));
908    }
909
910    if policy.paths.is_empty() != policy.operations.is_empty() {
911        return Err(GlovesError::InvalidInput(format!(
912            "agent '{agent_name}' must define both paths and operations for legacy private path access"
913        )));
914    }
915
916    if has_legacy_path_policy {
917        let mut path_aliases = BTreeSet::new();
918        for alias in &policy.paths {
919            if !path_aliases.insert(alias.as_str()) {
920                return Err(GlovesError::InvalidInput(format!(
921                    "agent '{agent_name}' contains duplicate private path alias '{alias}'"
922                )));
923            }
924            if !private_paths.is_empty() && !private_paths.contains_key(alias) {
925                return Err(GlovesError::InvalidInput(format!(
926                    "agent '{agent_name}' references unknown private path alias '{alias}'"
927                )));
928            }
929        }
930
931        let mut operations = BTreeSet::new();
932        for operation in &policy.operations {
933            if !operations.insert(*operation) {
934                return Err(GlovesError::InvalidInput(format!(
935                    "agent '{agent_name}' contains duplicate operation '{operation:?}'"
936                )));
937            }
938        }
939    }
940
941    if let Some(secret_policy) = policy.secrets.as_ref() {
942        validate_agent_secret_access_policy(agent_name, secret_policy)?;
943    }
944    if let Some(vault_policy) = policy.vault.as_ref() {
945        validate_agent_vault_access_policy(agent_name, vault_policy, vault_mounts)?;
946    }
947
948    Ok(())
949}
950
951fn validate_agent_secret_access_policy(
952    agent_name: &str,
953    policy: &AgentSecretsAccessFile,
954) -> Result<()> {
955    let policy = SecretAccessFile {
956        refs: policy.refs.clone(),
957        operations: policy.operations.clone(),
958    };
959    validate_secret_access_policy(agent_name, &policy)
960}
961
962fn validate_agent_vault_access_policy(
963    agent_name: &str,
964    policy: &AgentVaultAccessFile,
965    vault_mounts: &BTreeMap<String, impl Sized>,
966) -> Result<()> {
967    if policy.mounts.is_empty() {
968        return Err(GlovesError::InvalidInput(format!(
969            "vault access for agent '{agent_name}' must include at least one mount"
970        )));
971    }
972    if policy.operations.is_empty() {
973        return Err(GlovesError::InvalidInput(format!(
974            "vault access for agent '{agent_name}' must include at least one operation"
975        )));
976    }
977
978    let mut mount_names = BTreeSet::new();
979    for mount_name in &policy.mounts {
980        if !mount_names.insert(mount_name.as_str()) {
981            return Err(GlovesError::InvalidInput(format!(
982                "vault access for agent '{agent_name}' contains duplicate mount '{mount_name}'"
983            )));
984        }
985        if mount_name != "*" && !vault_mounts.is_empty() && !vault_mounts.contains_key(mount_name) {
986            return Err(GlovesError::InvalidInput(format!(
987                "vault access for agent '{agent_name}' references unknown mount '{mount_name}'"
988            )));
989        }
990    }
991
992    let mut operations = BTreeSet::new();
993    for operation in &policy.operations {
994        if !operations.insert(*operation) {
995            return Err(GlovesError::InvalidInput(format!(
996                "vault access for agent '{agent_name}' contains duplicate operation '{operation:?}'"
997            )));
998        }
999    }
1000
1001    Ok(())
1002}
1003
1004fn validate_secret_access_policy(agent_name: &str, policy: &SecretAccessFile) -> Result<()> {
1005    if policy.refs.is_empty() {
1006        return Err(GlovesError::InvalidInput(format!(
1007            "secret ACL for agent '{agent_name}' must include at least one ref pattern"
1008        )));
1009    }
1010    if policy.operations.is_empty() {
1011        return Err(GlovesError::InvalidInput(format!(
1012            "secret ACL for agent '{agent_name}' must include at least one operation"
1013        )));
1014    }
1015
1016    let mut patterns = BTreeSet::new();
1017    for pattern in &policy.refs {
1018        validate_secret_pattern(pattern)?;
1019        if !patterns.insert(pattern.as_str()) {
1020            return Err(GlovesError::InvalidInput(format!(
1021                "secret ACL for agent '{agent_name}' contains duplicate pattern '{pattern}'"
1022            )));
1023        }
1024    }
1025
1026    let mut operations = BTreeSet::new();
1027    for operation in &policy.operations {
1028        if !operations.insert(*operation) {
1029            return Err(GlovesError::InvalidInput(format!(
1030                "secret ACL for agent '{agent_name}' contains duplicate operation '{operation:?}'"
1031            )));
1032        }
1033    }
1034
1035    Ok(())
1036}
1037
1038fn validate_integration_config(name: &str, integration: &IntegrationConfigFile) -> Result<()> {
1039    validate_alias(name)?;
1040    let agent_literal = integration.agent.as_deref().ok_or_else(|| {
1041        GlovesError::InvalidInput(format!("integration '{name}' must declare an owning agent"))
1042    })?;
1043    AgentId::new(agent_literal)?;
1044    let _ = normalized_integration_segments(&integration.profiles, "profiles", name)?;
1045    let _ = normalized_integration_segments(&integration.slots, "slots", name)?;
1046    Ok(())
1047}
1048
1049fn normalized_integration_segments(
1050    values: &[String],
1051    field_name: &str,
1052    integration_name: &str,
1053) -> Result<Vec<String>> {
1054    let mut normalized = Vec::with_capacity(values.len());
1055    let mut seen = BTreeSet::new();
1056    for value in values {
1057        let trimmed = value.trim();
1058        if trimmed.is_empty() {
1059            return Err(GlovesError::InvalidInput(format!(
1060                "integration '{integration_name}' contains an empty {field_name} entry"
1061            )));
1062        }
1063        validate_alias(trimmed).map_err(|_| {
1064            GlovesError::InvalidInput(format!(
1065                "integration '{integration_name}' has invalid {field_name} entry '{trimmed}'"
1066            ))
1067        })?;
1068        if !seen.insert(trimmed.to_owned()) {
1069            return Err(GlovesError::InvalidInput(format!(
1070                "integration '{integration_name}' contains duplicate {field_name} entry '{trimmed}'"
1071            )));
1072        }
1073        normalized.push(trimmed.to_owned());
1074    }
1075    Ok(normalized)
1076}
1077
1078fn validate_secret_pipe_command_policy(
1079    command: &str,
1080    policy: &SecretPipeCommandPolicyFile,
1081) -> Result<()> {
1082    validate_pipe_command_name(command)?;
1083
1084    if !policy.require_url && policy.url_prefixes.is_empty() {
1085        return Err(GlovesError::InvalidInput(format!(
1086            "secrets.pipe.commands.{command} must set require_url = true or include at least one url_prefix"
1087        )));
1088    }
1089    if policy.require_url && policy.url_prefixes.is_empty() {
1090        return Err(GlovesError::InvalidInput(format!(
1091            "secrets.pipe.commands.{command} requires at least one url_prefix"
1092        )));
1093    }
1094
1095    let mut unique_prefixes = BTreeSet::new();
1096    for url_prefix in &policy.url_prefixes {
1097        validate_pipe_url_prefix(command, url_prefix)?;
1098        if !unique_prefixes.insert(url_prefix.as_str()) {
1099            return Err(GlovesError::InvalidInput(format!(
1100                "secrets.pipe.commands.{command} contains duplicate url_prefix '{url_prefix}'"
1101            )));
1102        }
1103    }
1104
1105    Ok(())
1106}
1107
1108fn validate_pipe_command_name(command: &str) -> Result<()> {
1109    if command.is_empty()
1110        || !command
1111            .chars()
1112            .all(|character| character.is_ascii_alphanumeric() || "._+-".contains(character))
1113    {
1114        return Err(GlovesError::InvalidInput(format!(
1115            "secrets.pipe.commands.{command} must be a bare executable name"
1116        )));
1117    }
1118    Ok(())
1119}
1120
1121fn validate_pipe_url_prefix(command: &str, url_prefix: &str) -> Result<()> {
1122    if url_prefix.trim().is_empty() {
1123        return Err(GlovesError::InvalidInput(format!(
1124            "secrets.pipe.commands.{command} contains an empty url_prefix"
1125        )));
1126    }
1127    if let Err(reason) = parse_policy_url_prefix(url_prefix) {
1128        return Err(GlovesError::InvalidInput(format!(
1129            "secrets.pipe.commands.{command} url_prefix '{url_prefix}' {reason}"
1130        )));
1131    }
1132    Ok(())
1133}
1134
1135fn parse_policy_url_prefix(url_prefix: &str) -> std::result::Result<(), String> {
1136    let remainder = if let Some(rest) = url_prefix.strip_prefix(URL_SCHEME_HTTP_PREFIX) {
1137        rest
1138    } else if let Some(rest) = url_prefix.strip_prefix(URL_SCHEME_HTTPS_PREFIX) {
1139        rest
1140    } else {
1141        return Err("must start with http:// or https://".to_owned());
1142    };
1143    if remainder.is_empty() {
1144        return Err("must include an authority after scheme".to_owned());
1145    }
1146
1147    let delimiter_index = remainder
1148        .find(|character: char| ['/', '?', '#'].contains(&character))
1149        .unwrap_or(remainder.len());
1150    let authority = &remainder[..delimiter_index];
1151    if authority.is_empty() {
1152        return Err("must include an authority after scheme".to_owned());
1153    }
1154    if authority.chars().any(char::is_whitespace) {
1155        return Err("must not contain whitespace in authority".to_owned());
1156    }
1157
1158    let suffix = &remainder[delimiter_index..];
1159    if suffix
1160        .chars()
1161        .any(|character| character == '?' || character == '#')
1162    {
1163        return Err("must not include query or fragment components".to_owned());
1164    }
1165    Ok(())
1166}
1167
1168fn validate_secret_pattern(pattern: &str) -> Result<()> {
1169    if pattern == "*" {
1170        return Ok(());
1171    }
1172
1173    if let Some(prefix) = pattern.strip_suffix("/*") {
1174        if prefix.is_empty() {
1175            return Err(GlovesError::InvalidInput(
1176                "secret ACL pattern '/*' is not allowed; use '*' for all secrets".to_owned(),
1177            ));
1178        }
1179        if prefix.contains('*') {
1180            return Err(GlovesError::InvalidInput(format!(
1181                "secret ACL pattern '{pattern}' may only use one trailing '*'"
1182            )));
1183        }
1184        SecretId::new(prefix).map_err(|_| {
1185            GlovesError::InvalidInput(format!(
1186                "secret ACL pattern '{pattern}' has an invalid namespace prefix"
1187            ))
1188        })?;
1189        return Ok(());
1190    }
1191
1192    if pattern.contains('*') {
1193        return Err(GlovesError::InvalidInput(format!(
1194            "secret ACL pattern '{pattern}' must be '*', '<namespace>/*', or an exact secret id"
1195        )));
1196    }
1197
1198    SecretId::new(pattern).map_err(|_| {
1199        GlovesError::InvalidInput(format!(
1200            "secret ACL pattern '{pattern}' is not a valid secret id"
1201        ))
1202    })?;
1203    Ok(())
1204}
1205
1206fn secret_pattern_matches(pattern: &str, secret_name: &str) -> bool {
1207    if pattern == "*" {
1208        return true;
1209    }
1210    if let Some(prefix) = pattern.strip_suffix("/*") {
1211        return secret_name.len() > prefix.len()
1212            && secret_name.starts_with(prefix)
1213            && secret_name.as_bytes().get(prefix.len()) == Some(&b'/');
1214    }
1215    secret_name == pattern
1216}
1217
1218fn resolve_path_value(value: &str, source_dir: &Path) -> Result<PathBuf> {
1219    validate_path_literal(value, "path")?;
1220
1221    let expanded = expand_home(value)?;
1222    let absolute = if expanded.is_absolute() {
1223        expanded
1224    } else {
1225        source_dir.join(expanded)
1226    };
1227
1228    if let Ok(canonical) = fs::canonicalize(&absolute) {
1229        return Ok(canonical);
1230    }
1231    Ok(normalize_path(&absolute))
1232}
1233
1234fn validate_path_literal(value: &str, label: &str) -> Result<()> {
1235    if value.trim().is_empty() {
1236        return Err(GlovesError::InvalidInput(format!(
1237            "{label} cannot be empty"
1238        )));
1239    }
1240    Ok(())
1241}
1242
1243fn validate_alias(alias: &str) -> Result<()> {
1244    if alias.is_empty() {
1245        return Err(GlovesError::InvalidInput(
1246            "private path alias cannot be empty".to_owned(),
1247        ));
1248    }
1249    if !alias
1250        .chars()
1251        .all(|character| character.is_ascii_alphanumeric() || character == '_' || character == '-')
1252    {
1253        return Err(GlovesError::InvalidInput(format!(
1254            "invalid private path alias '{}': use [a-zA-Z0-9_-]",
1255            alias
1256        )));
1257    }
1258    Ok(())
1259}
1260
1261fn validate_duration_literal(value: &str, label: &str) -> Result<()> {
1262    if value.is_empty() {
1263        return Err(GlovesError::InvalidInput(format!(
1264            "{label} cannot be empty"
1265        )));
1266    }
1267
1268    let (number, unit) = value.split_at(value.len().saturating_sub(1));
1269    let amount = number.parse::<i64>().map_err(|_| {
1270        GlovesError::InvalidInput(format!("{label} must be a duration like 30m, 1h, or 7d"))
1271    })?;
1272    if amount <= 0 {
1273        return Err(GlovesError::InvalidInput(format!(
1274            "{label} must be greater than zero"
1275        )));
1276    }
1277
1278    if !matches!(unit, "s" | "m" | "h" | "d") {
1279        return Err(GlovesError::InvalidInput(format!(
1280            "{label} must use one of s, m, h, d"
1281        )));
1282    }
1283
1284    Ok(())
1285}
1286
1287fn expand_home(value: &str) -> Result<PathBuf> {
1288    if value == "~" {
1289        let home = std::env::var_os("HOME")
1290            .ok_or_else(|| GlovesError::InvalidInput("HOME is not set".to_owned()))?;
1291        return Ok(PathBuf::from(home));
1292    }
1293
1294    if let Some(rest) = value.strip_prefix("~/") {
1295        let home = std::env::var_os("HOME")
1296            .ok_or_else(|| GlovesError::InvalidInput("HOME is not set".to_owned()))?;
1297        return Ok(PathBuf::from(home).join(rest));
1298    }
1299
1300    if value.starts_with('~') {
1301        return Err(GlovesError::InvalidInput(
1302            "only '~' and '~/' home expansion are supported".to_owned(),
1303        ));
1304    }
1305
1306    Ok(PathBuf::from(value))
1307}
1308
1309fn normalize_path(path: &Path) -> PathBuf {
1310    let is_absolute = path.is_absolute();
1311    let mut normalized = PathBuf::new();
1312
1313    for component in path.components() {
1314        match component {
1315            Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
1316            Component::RootDir => normalized.push(component.as_os_str()),
1317            Component::CurDir => {}
1318            Component::ParentDir => {
1319                if !normalized.pop() && !is_absolute {
1320                    normalized.push("..");
1321                }
1322            }
1323            Component::Normal(part) => normalized.push(part),
1324        }
1325    }
1326
1327    if normalized.as_os_str().is_empty() {
1328        if is_absolute {
1329            PathBuf::from(std::path::MAIN_SEPARATOR.to_string())
1330        } else {
1331            PathBuf::from(".")
1332        }
1333    } else {
1334        normalized
1335    }
1336}
1337
1338fn absolutize_path(path: &Path, cwd: &Path) -> PathBuf {
1339    if path.is_absolute() {
1340        normalize_path(path)
1341    } else {
1342        normalize_path(&cwd.join(path))
1343    }
1344}
1345
1346fn validate_config_file_permissions(path: &Path, has_private_paths: bool) -> Result<()> {
1347    let metadata = fs::symlink_metadata(path)?;
1348    if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
1349        return Err(GlovesError::InvalidInput(format!(
1350            "config path must be a regular file: {}",
1351            path.display()
1352        )));
1353    }
1354
1355    #[cfg(unix)]
1356    {
1357        let mode = metadata.permissions().mode() & 0o777;
1358        if mode & 0o022 != 0 {
1359            return Err(GlovesError::InvalidInput(format!(
1360                "config file must not be group/world writable: {}",
1361                path.display()
1362            )));
1363        }
1364
1365        if has_private_paths {
1366            let has_exec_bits = mode & 0o111 != 0;
1367            let has_world_bits = mode & 0o007 != 0;
1368            let has_group_write_or_exec = mode & 0o030 != 0;
1369            if has_exec_bits || has_world_bits || has_group_write_or_exec {
1370                return Err(GlovesError::InvalidInput(format!(
1371                    "config file with private paths must be private (recommended 0600/0640): {}",
1372                    path.display()
1373                )));
1374            }
1375        }
1376    }
1377
1378    Ok(())
1379}
1380
1381fn is_regular_config_candidate(path: &Path) -> bool {
1382    let Ok(metadata) = fs::symlink_metadata(path) else {
1383        return false;
1384    };
1385    !metadata.file_type().is_symlink() && metadata.file_type().is_file()
1386}
1387
1388#[cfg(test)]
1389mod tests {
1390    use super::*;
1391    use std::{
1392        ffi::OsString,
1393        sync::{Mutex, OnceLock},
1394        time::{SystemTime, UNIX_EPOCH},
1395    };
1396
1397    #[cfg(unix)]
1398    use std::os::unix::fs::{symlink, PermissionsExt};
1399
1400    static TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1401
1402    fn test_lock() -> std::sync::MutexGuard<'static, ()> {
1403        TEST_LOCK
1404            .get_or_init(|| Mutex::new(()))
1405            .lock()
1406            .unwrap_or_else(|poisoned| poisoned.into_inner())
1407    }
1408
1409    fn unique_temp_dir(label: &str) -> PathBuf {
1410        let unique = SystemTime::now()
1411            .duration_since(UNIX_EPOCH)
1412            .unwrap_or_default()
1413            .as_nanos();
1414        let temp_root = PathBuf::from("/tmp");
1415        let base_dir = if temp_root.is_dir() {
1416            temp_root
1417        } else {
1418            std::env::temp_dir()
1419        };
1420        let path = base_dir.join(format!(
1421            "gloves-config-{label}-{}-{unique}",
1422            std::process::id()
1423        ));
1424        fs::create_dir_all(&path).unwrap();
1425        path
1426    }
1427
1428    fn cleanup_dir(path: &Path) {
1429        let _ = fs::remove_dir_all(path);
1430    }
1431
1432    fn valid_config(root_literal: &str, private_path_literal: &str) -> String {
1433        format!(
1434            r#"
1435version = 1
1436
1437[paths]
1438root = "{root_literal}"
1439
1440[private_paths]
1441runtime = "{private_path_literal}"
1442
1443[daemon]
1444bind = "127.0.0.1:7789"
1445io_timeout_seconds = 9
1446request_limit_bytes = 32768
1447
1448[vault]
1449mode = "required"
1450
1451[defaults]
1452agent_id = "devy"
1453secret_ttl_days = 7
1454vault_mount_ttl = "2h"
1455vault_secret_ttl_days = 90
1456vault_secret_length_bytes = 48
1457
1458[agents.devy]
1459paths = ["runtime"]
1460operations = ["read", "write"]
1461
1462[secrets.acl.devy]
1463paths = ["agents/devy/*", "shared/database-url"]
1464operations = ["read", "list"]
1465
1466[secrets.pipe.commands.curl]
1467require_url = true
1468url_prefixes = ["https://api.example.com/v1/"]
1469"#
1470        )
1471    }
1472
1473    struct HomeGuard {
1474        previous_home: Option<OsString>,
1475    }
1476
1477    impl HomeGuard {
1478        fn set(home: &Path) -> Self {
1479            let previous_home = std::env::var_os("HOME");
1480            std::env::set_var("HOME", home);
1481            Self { previous_home }
1482        }
1483    }
1484
1485    impl Drop for HomeGuard {
1486        fn drop(&mut self) {
1487            if let Some(previous_home) = &self.previous_home {
1488                std::env::set_var("HOME", previous_home);
1489            } else {
1490                std::env::remove_var("HOME");
1491            }
1492        }
1493    }
1494
1495    #[test]
1496    fn parse_from_str_builds_effective_config_and_accessors() {
1497        let _lock = test_lock();
1498        let temp_dir = unique_temp_dir("parse");
1499        let source_path = temp_dir.join(CONFIG_FILE_NAME);
1500        let root_dir = temp_dir.join("secrets-root");
1501        let private_dir = temp_dir.join("private").join("runtime");
1502        fs::create_dir_all(&root_dir).unwrap();
1503        fs::create_dir_all(&private_dir).unwrap();
1504
1505        let config = GlovesConfig::parse_from_str(
1506            &valid_config("./secrets-root", "./private/runtime"),
1507            &source_path,
1508        )
1509        .unwrap();
1510
1511        assert_eq!(config.source_path, source_path);
1512        assert_eq!(config.root, fs::canonicalize(&root_dir).unwrap());
1513        assert_eq!(
1514            config.private_paths.get("runtime"),
1515            Some(&fs::canonicalize(&private_dir).unwrap())
1516        );
1517        assert_eq!(config.daemon.bind, "127.0.0.1:7789");
1518        assert_eq!(config.daemon.io_timeout_seconds, 9);
1519        assert_eq!(config.daemon.request_limit_bytes, 32768);
1520        assert_eq!(config.vault.mode, VaultMode::Required);
1521        assert_eq!(config.defaults.agent_id.as_str(), "devy");
1522        assert_eq!(config.defaults.secret_ttl_days, 7);
1523        assert_eq!(config.defaults.vault_mount_ttl, "2h");
1524        assert_eq!(config.defaults.vault_secret_ttl_days, 90);
1525        assert_eq!(config.defaults.vault_secret_length_bytes, 48);
1526        assert!(config.has_secret_acl());
1527
1528        let agent_id = AgentId::new("devy").unwrap();
1529        let paths = config.agent_paths(&agent_id).unwrap();
1530        assert_eq!(paths.len(), 1);
1531        assert_eq!(paths[0].alias, "runtime");
1532        assert_eq!(
1533            paths[0].operations,
1534            vec![PathOperation::Read, PathOperation::Write]
1535        );
1536
1537        let secret_policy = config.secret_access_policy(&agent_id).unwrap();
1538        assert!(secret_policy.allows_operation(SecretAclOperation::Read));
1539        assert!(secret_policy.allows_secret("agents/devy/api-keys/anthropic"));
1540        assert!(secret_policy.allows_secret("shared/database-url"));
1541        assert!(!secret_policy.allows_secret("agents/webhook/api-keys/anthropic"));
1542
1543        let pipe_policy = config.secret_pipe_command_policy("curl").unwrap();
1544        assert!(pipe_policy.require_url);
1545        assert_eq!(
1546            pipe_policy.url_prefixes,
1547            vec!["https://api.example.com/v1/".to_owned()]
1548        );
1549        assert!(config
1550            .secret_access_policy(&AgentId::new("webhook").unwrap())
1551            .is_none());
1552        assert!(config.secret_pipe_command_policy("wget").is_none());
1553
1554        let mut config_without_acl = config.clone();
1555        config_without_acl.secret_access.clear();
1556        assert!(!config_without_acl.has_secret_acl());
1557
1558        cleanup_dir(&temp_dir);
1559    }
1560
1561    #[test]
1562    fn resolve_config_path_honors_flag_env_discovery_and_no_config() {
1563        let _lock = test_lock();
1564        let temp_dir = unique_temp_dir("resolve");
1565        let nested_dir = temp_dir.join("workspace").join("nested");
1566        fs::create_dir_all(&nested_dir).unwrap();
1567
1568        let discovered_path = temp_dir.join("workspace").join(CONFIG_FILE_NAME);
1569        fs::write(&discovered_path, "version = 1\n").unwrap();
1570
1571        let flag_path = nested_dir.join("custom.toml");
1572        fs::write(&flag_path, "version = 1\n").unwrap();
1573        let env_path = nested_dir.join("env.toml");
1574        fs::write(&env_path, "version = 1\n").unwrap();
1575
1576        let flag_selection =
1577            resolve_config_path(Some(Path::new("custom.toml")), None, false, &nested_dir).unwrap();
1578        assert_eq!(flag_selection.source, ConfigSource::Flag);
1579        assert_eq!(flag_selection.path, Some(flag_path.clone()));
1580
1581        let env_selection =
1582            resolve_config_path(None, Some("env.toml"), false, &nested_dir).unwrap();
1583        assert_eq!(env_selection.source, ConfigSource::Env);
1584        assert_eq!(env_selection.path, Some(env_path.clone()));
1585
1586        let discovered_selection = resolve_config_path(None, None, false, &nested_dir).unwrap();
1587        assert_eq!(discovered_selection.source, ConfigSource::Discovered);
1588        assert_eq!(discovered_selection.path, Some(discovered_path.clone()));
1589
1590        let none_selection = resolve_config_path(None, None, true, &nested_dir).unwrap();
1591        assert_eq!(none_selection.source, ConfigSource::None);
1592        assert!(none_selection.path.is_none());
1593
1594        let env_error = resolve_config_path(None, Some("   "), false, &nested_dir).unwrap_err();
1595        assert!(env_error
1596            .to_string()
1597            .contains("GLOVES_CONFIG cannot be empty"));
1598
1599        let missing_error =
1600            resolve_config_path(Some(Path::new("missing.toml")), None, false, &nested_dir)
1601                .unwrap_err();
1602        assert!(missing_error
1603            .to_string()
1604            .contains("config file must be a regular file"));
1605
1606        cleanup_dir(&temp_dir);
1607    }
1608
1609    #[test]
1610    fn helper_functions_cover_path_resolution_and_home_expansion() {
1611        let _lock = test_lock();
1612        let temp_dir = unique_temp_dir("paths");
1613        let home_dir = temp_dir.join("home");
1614        fs::create_dir_all(&home_dir).unwrap();
1615        let _home_guard = HomeGuard::set(&home_dir);
1616
1617        assert_eq!(expand_home("~").unwrap(), home_dir);
1618        assert_eq!(expand_home("~/bin").unwrap(), home_dir.join("bin"));
1619        assert!(expand_home("~other/bin")
1620            .unwrap_err()
1621            .to_string()
1622            .contains("only '~' and '~/' home expansion are supported"));
1623
1624        assert_eq!(
1625            normalize_path(Path::new("foo/./bar/../baz")),
1626            PathBuf::from("foo/baz")
1627        );
1628        assert_eq!(
1629            normalize_path(Path::new("/tmp/../var/./lib")),
1630            PathBuf::from("/var/lib")
1631        );
1632        assert_eq!(
1633            absolutize_path(Path::new("nested/../config.toml"), Path::new("/tmp/work")),
1634            PathBuf::from("/tmp/work/config.toml")
1635        );
1636        assert_eq!(
1637            absolutize_path(Path::new("/tmp/./gloves.toml"), Path::new("/unused")),
1638            PathBuf::from("/tmp/gloves.toml")
1639        );
1640
1641        let resolved_existing = resolve_path_value("~/bin", Path::new("/unused")).unwrap();
1642        assert_eq!(resolved_existing, home_dir.join("bin"));
1643        let resolved_relative = resolve_path_value("./secrets/../secrets-root", &temp_dir).unwrap();
1644        assert_eq!(resolved_relative, temp_dir.join("secrets-root"));
1645
1646        assert!(validate_path_literal("", "paths.root")
1647            .unwrap_err()
1648            .to_string()
1649            .contains("paths.root cannot be empty"));
1650
1651        cleanup_dir(&temp_dir);
1652    }
1653
1654    #[test]
1655    fn daemon_defaults_and_vault_validation_cover_failure_modes() {
1656        let daemon_defaults = resolve_daemon_config(&DaemonConfigFile::default()).unwrap();
1657        assert_eq!(daemon_defaults.bind, DEFAULT_DAEMON_BIND);
1658        assert_eq!(
1659            daemon_defaults.io_timeout_seconds,
1660            DEFAULT_DAEMON_IO_TIMEOUT_SECONDS
1661        );
1662        assert_eq!(
1663            daemon_defaults.request_limit_bytes,
1664            DEFAULT_DAEMON_REQUEST_LIMIT_BYTES
1665        );
1666
1667        assert!(resolve_daemon_config(&DaemonConfigFile {
1668            bind: Some("127.0.0.1:0".to_owned()),
1669            io_timeout_seconds: None,
1670            request_limit_bytes: None,
1671        })
1672        .unwrap_err()
1673        .to_string()
1674        .contains("daemon bind port must be non-zero"));
1675        assert!(resolve_daemon_config(&DaemonConfigFile {
1676            bind: Some("0.0.0.0:7788".to_owned()),
1677            io_timeout_seconds: None,
1678            request_limit_bytes: None,
1679        })
1680        .unwrap_err()
1681        .to_string()
1682        .contains("daemon bind address must be loopback"));
1683        assert!(resolve_daemon_config(&DaemonConfigFile {
1684            bind: None,
1685            io_timeout_seconds: Some(0),
1686            request_limit_bytes: None,
1687        })
1688        .unwrap_err()
1689        .to_string()
1690        .contains("io_timeout_seconds must be greater than zero"));
1691        assert!(resolve_daemon_config(&DaemonConfigFile {
1692            bind: None,
1693            io_timeout_seconds: None,
1694            request_limit_bytes: Some(0),
1695        })
1696        .unwrap_err()
1697        .to_string()
1698        .contains("request_limit_bytes must be greater than zero"));
1699
1700        let default_config = resolve_default_config(&DefaultsConfigFile::default()).unwrap();
1701        assert_eq!(default_config.agent_id.as_str(), DEFAULT_AGENT_ID);
1702        assert_eq!(default_config.secret_ttl_days, DEFAULT_SECRET_TTL_DAYS);
1703        assert_eq!(default_config.vault_mount_ttl, DEFAULT_VAULT_MOUNT_TTL);
1704        assert_eq!(
1705            default_config.vault_secret_ttl_days,
1706            DEFAULT_VAULT_SECRET_TTL_DAYS
1707        );
1708        assert_eq!(
1709            default_config.vault_secret_length_bytes,
1710            DEFAULT_VAULT_SECRET_LENGTH_BYTES
1711        );
1712
1713        assert!(resolve_default_config(&DefaultsConfigFile {
1714            agent_id: Some("bad agent".to_owned()),
1715            ..DefaultsConfigFile::default()
1716        })
1717        .is_err());
1718        assert!(resolve_default_config(&DefaultsConfigFile {
1719            secret_ttl_days: Some(0),
1720            ..DefaultsConfigFile::default()
1721        })
1722        .unwrap_err()
1723        .to_string()
1724        .contains("secret_ttl_days must be greater than zero"));
1725        assert!(resolve_default_config(&DefaultsConfigFile {
1726            vault_mount_ttl: Some("12x".to_owned()),
1727            ..DefaultsConfigFile::default()
1728        })
1729        .unwrap_err()
1730        .to_string()
1731        .contains("vault_mount_ttl must use one of s, m, h, d"));
1732        assert!(resolve_default_config(&DefaultsConfigFile {
1733            vault_secret_ttl_days: Some(0),
1734            ..DefaultsConfigFile::default()
1735        })
1736        .unwrap_err()
1737        .to_string()
1738        .contains("vault_secret_ttl_days must be greater than zero"));
1739        assert!(resolve_default_config(&DefaultsConfigFile {
1740            vault_secret_length_bytes: Some(0),
1741            ..DefaultsConfigFile::default()
1742        })
1743        .unwrap_err()
1744        .to_string()
1745        .contains("vault_secret_length_bytes must be greater than zero"));
1746
1747        assert_eq!(
1748            resolve_vault_config(&VaultConfigFile::default(), Path::new("."))
1749                .unwrap()
1750                .mode,
1751            VaultMode::Auto
1752        );
1753        assert_eq!(
1754            resolve_vault_config(
1755                &VaultConfigFile {
1756                    mode: Some(VaultMode::Disabled),
1757                    mounts: BTreeMap::new(),
1758                },
1759                Path::new(".")
1760            )
1761            .unwrap()
1762            .mode,
1763            VaultMode::Disabled
1764        );
1765
1766        assert!(validate_duration_literal("", "defaults.vault_mount_ttl")
1767            .unwrap_err()
1768            .to_string()
1769            .contains("defaults.vault_mount_ttl cannot be empty"));
1770        assert!(validate_duration_literal("0h", "defaults.vault_mount_ttl")
1771            .unwrap_err()
1772            .to_string()
1773            .contains("defaults.vault_mount_ttl must be greater than zero"));
1774    }
1775
1776    #[test]
1777    fn policy_validation_helpers_cover_duplicates_and_invalid_patterns() {
1778        let private_paths = BTreeMap::from([("runtime".to_owned(), PathBuf::from("/tmp/runtime"))]);
1779        let vault_mounts =
1780            BTreeMap::from([("contacts".to_owned(), PathBuf::from("/tmp/contacts"))]);
1781
1782        assert!(validate_agent_policy(
1783            "devy",
1784            &AgentAccessFile {
1785                paths: Vec::new(),
1786                operations: vec![PathOperation::Read],
1787                secrets: None,
1788                vault: None,
1789            },
1790            &private_paths,
1791            &vault_mounts,
1792        )
1793        .unwrap_err()
1794        .to_string()
1795        .contains("must define both paths and operations"));
1796        assert!(validate_agent_policy(
1797            "devy",
1798            &AgentAccessFile {
1799                paths: vec!["runtime".to_owned()],
1800                operations: Vec::new(),
1801                secrets: None,
1802                vault: None,
1803            },
1804            &private_paths,
1805            &vault_mounts,
1806        )
1807        .unwrap_err()
1808        .to_string()
1809        .contains("must define both paths and operations"));
1810        assert!(validate_agent_policy(
1811            "devy",
1812            &AgentAccessFile {
1813                paths: vec!["missing".to_owned()],
1814                operations: vec![PathOperation::Read],
1815                secrets: None,
1816                vault: None,
1817            },
1818            &private_paths,
1819            &vault_mounts,
1820        )
1821        .unwrap_err()
1822        .to_string()
1823        .contains("references unknown private path alias"));
1824        assert!(validate_agent_policy(
1825            "devy",
1826            &AgentAccessFile {
1827                paths: vec!["runtime".to_owned(), "runtime".to_owned()],
1828                operations: vec![PathOperation::Read],
1829                secrets: None,
1830                vault: None,
1831            },
1832            &private_paths,
1833            &vault_mounts,
1834        )
1835        .unwrap_err()
1836        .to_string()
1837        .contains("duplicate private path alias"));
1838        assert!(validate_agent_policy(
1839            "devy",
1840            &AgentAccessFile {
1841                paths: vec!["runtime".to_owned()],
1842                operations: vec![PathOperation::Read, PathOperation::Read],
1843                secrets: None,
1844                vault: None,
1845            },
1846            &private_paths,
1847            &vault_mounts,
1848        )
1849        .unwrap_err()
1850        .to_string()
1851        .contains("duplicate operation"));
1852
1853        let valid_secret_policy = SecretAccessFile {
1854            refs: vec!["agents/devy/*".to_owned(), "shared/database-url".to_owned()],
1855            operations: vec![SecretAclOperation::Read, SecretAclOperation::List],
1856        };
1857        validate_secret_access_policy("devy", &valid_secret_policy).unwrap();
1858        assert!(validate_secret_access_policy(
1859            "devy",
1860            &SecretAccessFile {
1861                refs: Vec::new(),
1862                operations: vec![SecretAclOperation::Read],
1863            },
1864        )
1865        .unwrap_err()
1866        .to_string()
1867        .contains("must include at least one ref pattern"));
1868        assert!(validate_secret_access_policy(
1869            "devy",
1870            &SecretAccessFile {
1871                refs: vec!["*".to_owned()],
1872                operations: Vec::new(),
1873            },
1874        )
1875        .unwrap_err()
1876        .to_string()
1877        .contains("must include at least one operation"));
1878        assert!(validate_secret_access_policy(
1879            "devy",
1880            &SecretAccessFile {
1881                refs: vec!["*".to_owned(), "*".to_owned()],
1882                operations: vec![SecretAclOperation::Read],
1883            },
1884        )
1885        .unwrap_err()
1886        .to_string()
1887        .contains("duplicate pattern"));
1888        assert!(validate_secret_access_policy(
1889            "devy",
1890            &SecretAccessFile {
1891                refs: vec!["*".to_owned()],
1892                operations: vec![SecretAclOperation::Read, SecretAclOperation::Read],
1893            },
1894        )
1895        .unwrap_err()
1896        .to_string()
1897        .contains("duplicate operation"));
1898
1899        validate_secret_pattern("*").unwrap();
1900        validate_secret_pattern("agents/devy/*").unwrap();
1901        validate_secret_pattern("shared/database-url").unwrap();
1902        assert!(validate_secret_pattern("/*")
1903            .unwrap_err()
1904            .to_string()
1905            .contains("is not allowed"));
1906        assert!(validate_secret_pattern("agents/*/broken")
1907            .unwrap_err()
1908            .to_string()
1909            .contains("must be '*', '<namespace>/*', or an exact secret id"));
1910        assert!(validate_secret_pattern("agents/devy*")
1911            .unwrap_err()
1912            .to_string()
1913            .contains("must be '*', '<namespace>/*', or an exact secret id"));
1914        assert!(validate_secret_pattern("bad secret")
1915            .unwrap_err()
1916            .to_string()
1917            .contains("is not a valid secret id"));
1918
1919        assert!(secret_pattern_matches("*", "shared/database-url"));
1920        assert!(secret_pattern_matches(
1921            "agents/devy/*",
1922            "agents/devy/api-keys/anthropic"
1923        ));
1924        assert!(!secret_pattern_matches("agents/devy/*", "agents/devy"));
1925        assert!(!secret_pattern_matches(
1926            "agents/devy/*",
1927            "agents/webhook/api-keys/anthropic"
1928        ));
1929        assert!(secret_pattern_matches(
1930            "shared/database-url",
1931            "shared/database-url"
1932        ));
1933    }
1934
1935    #[test]
1936    fn pipe_policy_and_raw_config_validation_cover_edge_cases() {
1937        validate_secret_pipe_command_policy(
1938            "curl",
1939            &SecretPipeCommandPolicyFile {
1940                require_url: true,
1941                url_prefixes: vec!["https://api.example.com/".to_owned()],
1942            },
1943        )
1944        .unwrap();
1945
1946        assert!(validate_pipe_command_name("curl").is_ok());
1947        assert!(validate_pipe_command_name("curl --fail")
1948            .unwrap_err()
1949            .to_string()
1950            .contains("must be a bare executable name"));
1951        assert!(validate_secret_pipe_command_policy(
1952            "curl",
1953            &SecretPipeCommandPolicyFile::default(),
1954        )
1955        .unwrap_err()
1956        .to_string()
1957        .contains("must set require_url = true or include at least one url_prefix"));
1958        assert!(validate_secret_pipe_command_policy(
1959            "curl",
1960            &SecretPipeCommandPolicyFile {
1961                require_url: true,
1962                url_prefixes: Vec::new(),
1963            },
1964        )
1965        .unwrap_err()
1966        .to_string()
1967        .contains("requires at least one url_prefix"));
1968        assert!(validate_secret_pipe_command_policy(
1969            "curl",
1970            &SecretPipeCommandPolicyFile {
1971                require_url: false,
1972                url_prefixes: vec![
1973                    "https://api.example.com/".to_owned(),
1974                    "https://api.example.com/".to_owned()
1975                ],
1976            },
1977        )
1978        .unwrap_err()
1979        .to_string()
1980        .contains("duplicate url_prefix"));
1981        assert!(validate_pipe_url_prefix("curl", "   ")
1982            .unwrap_err()
1983            .to_string()
1984            .contains("contains an empty url_prefix"));
1985
1986        assert!(parse_policy_url_prefix("ftp://example.com")
1987            .unwrap_err()
1988            .contains("must start with http:// or https://"));
1989        assert!(parse_policy_url_prefix("https://")
1990            .unwrap_err()
1991            .contains("must include an authority after scheme"));
1992        assert!(parse_policy_url_prefix("https://bad host/path")
1993            .unwrap_err()
1994            .contains("must not contain whitespace in authority"));
1995        assert!(parse_policy_url_prefix("https://example.com/path?query")
1996            .unwrap_err()
1997            .contains("must not include query or fragment components"));
1998        assert!(parse_policy_url_prefix("https://example.com/path#fragment")
1999            .unwrap_err()
2000            .contains("must not include query or fragment components"));
2001
2002        assert!(validate_alias("runtime-1").is_ok());
2003        assert!(validate_alias("")
2004            .unwrap_err()
2005            .to_string()
2006            .contains("alias cannot be empty"));
2007        assert!(validate_alias("bad/alias")
2008            .unwrap_err()
2009            .to_string()
2010            .contains("invalid private path alias"));
2011
2012        assert!(
2013            GlovesConfig::parse_from_str("version = 3\n", Path::new("/tmp/.gloves.toml"))
2014                .unwrap_err()
2015                .to_string()
2016                .contains("unsupported config version")
2017        );
2018        assert!(GlovesConfig::parse_from_str(
2019            "version = 1\nunknown = true\n",
2020            Path::new("/tmp/.gloves.toml")
2021        )
2022        .unwrap_err()
2023        .to_string()
2024        .contains("invalid config TOML"));
2025    }
2026
2027    #[test]
2028    fn agent_paths_reports_unknown_alias_when_config_is_mutated() {
2029        let mut config = GlovesConfig {
2030            source_path: PathBuf::from("/tmp/.gloves.toml"),
2031            root: PathBuf::from("/tmp/root"),
2032            private_paths: BTreeMap::new(),
2033            daemon: DaemonBootstrapConfig {
2034                bind: DEFAULT_DAEMON_BIND.to_owned(),
2035                io_timeout_seconds: DEFAULT_DAEMON_IO_TIMEOUT_SECONDS,
2036                request_limit_bytes: DEFAULT_DAEMON_REQUEST_LIMIT_BYTES,
2037            },
2038            vault: VaultBootstrapConfig {
2039                mode: VaultMode::Auto,
2040                mounts: BTreeMap::new(),
2041            },
2042            defaults: DefaultBootstrapConfig {
2043                agent_id: AgentId::new(DEFAULT_AGENT_ID).unwrap(),
2044                secret_ttl_days: DEFAULT_SECRET_TTL_DAYS,
2045                vault_mount_ttl: DEFAULT_VAULT_MOUNT_TTL.to_owned(),
2046                vault_secret_ttl_days: DEFAULT_VAULT_SECRET_TTL_DAYS,
2047                vault_secret_length_bytes: DEFAULT_VAULT_SECRET_LENGTH_BYTES,
2048            },
2049            agents: BTreeMap::from([(
2050                "devy".to_owned(),
2051                AgentAccessPolicy {
2052                    path_aliases: vec!["runtime".to_owned()],
2053                    operations: vec![PathOperation::Read],
2054                },
2055            )]),
2056            secret_access: BTreeMap::new(),
2057            agent_vault_access: BTreeMap::new(),
2058            integrations: BTreeMap::new(),
2059            secret_pipe_commands: BTreeMap::new(),
2060        };
2061
2062        let error = config
2063            .agent_paths(&AgentId::new("devy").unwrap())
2064            .unwrap_err();
2065        assert!(error
2066            .to_string()
2067            .contains("references unknown private path alias"));
2068
2069        config
2070            .private_paths
2071            .insert("runtime".to_owned(), PathBuf::from("/tmp/runtime"));
2072        let missing_agent = config
2073            .agent_paths(&AgentId::new("webhook").unwrap())
2074            .unwrap_err();
2075        assert!(matches!(missing_agent, GlovesError::NotFound));
2076    }
2077
2078    #[test]
2079    fn discover_config_walks_upward_and_ignores_missing_candidates() {
2080        let _lock = test_lock();
2081        let temp_dir = unique_temp_dir("discover");
2082        let workspace_dir = temp_dir.join("workspace");
2083        let nested_dir = workspace_dir.join("nested").join("child");
2084        fs::create_dir_all(&nested_dir).unwrap();
2085
2086        let config_path = workspace_dir.join(CONFIG_FILE_NAME);
2087        fs::write(&config_path, "version = 1\n").unwrap();
2088
2089        assert_eq!(discover_config(&nested_dir), Some(config_path.clone()));
2090        assert!(is_regular_config_candidate(&config_path));
2091        assert!(!is_regular_config_candidate(&workspace_dir));
2092        assert_eq!(discover_config(temp_dir.join("missing")), None);
2093
2094        cleanup_dir(&temp_dir);
2095    }
2096
2097    #[cfg(unix)]
2098    #[test]
2099    fn load_from_path_and_permission_validation_cover_unix_rules() {
2100        let _lock = test_lock();
2101        let temp_dir = unique_temp_dir("load");
2102        let config_path = temp_dir.join(CONFIG_FILE_NAME);
2103        let regular_path = temp_dir.join("regular.toml");
2104        let symlink_path = temp_dir.join("config-link.toml");
2105        fs::create_dir_all(temp_dir.join("private").join("runtime")).unwrap();
2106        fs::create_dir_all(temp_dir.join("secrets-root")).unwrap();
2107
2108        fs::write(
2109            &config_path,
2110            valid_config("./secrets-root", "./private/runtime"),
2111        )
2112        .unwrap();
2113        fs::set_permissions(&config_path, fs::Permissions::from_mode(0o640)).unwrap();
2114        let loaded = GlovesConfig::load_from_path(&config_path).unwrap();
2115        assert_eq!(loaded.defaults.agent_id.as_str(), "devy");
2116
2117        fs::write(&regular_path, "version = 1\n").unwrap();
2118        fs::set_permissions(&regular_path, fs::Permissions::from_mode(0o666)).unwrap();
2119        assert!(validate_config_file_permissions(&regular_path, false)
2120            .unwrap_err()
2121            .to_string()
2122            .contains("must not be group/world writable"));
2123
2124        fs::set_permissions(&regular_path, fs::Permissions::from_mode(0o750)).unwrap();
2125        assert!(validate_config_file_permissions(&regular_path, true)
2126            .unwrap_err()
2127            .to_string()
2128            .contains("must be private"));
2129
2130        symlink(&regular_path, &symlink_path).unwrap();
2131        assert!(validate_config_file_permissions(&symlink_path, false)
2132            .unwrap_err()
2133            .to_string()
2134            .contains("must be a regular file"));
2135
2136        assert!(GlovesConfig::load_from_path(temp_dir.join("missing.toml"))
2137            .unwrap_err()
2138            .to_string()
2139            .contains("config file does not exist"));
2140
2141        cleanup_dir(&temp_dir);
2142    }
2143}