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