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";
21pub 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
29pub const CONFIG_FILE_NAME: &str = ".gloves.toml";
31pub const CONFIG_SCHEMA_VERSION: u32 = CONFIG_VERSION_V1;
33
34#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
36pub enum ConfigSource {
37 Flag,
39 Env,
41 Discovered,
43 None,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49pub struct ConfigSelection {
50 pub source: ConfigSource,
52 pub path: Option<PathBuf>,
54}
55
56#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
58#[serde(rename_all = "lowercase")]
59pub enum PathOperation {
60 Read,
62 Write,
64 List,
66 Mount,
68}
69
70#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
72#[serde(rename_all = "lowercase")]
73pub enum SecretAclOperation {
74 Read,
76 Write,
78 List,
80 Revoke,
82 Request,
84 Status,
86 Approve,
88 Deny,
90}
91
92#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
94#[serde(rename_all = "lowercase")]
95pub enum VaultMode {
96 Auto,
98 Required,
100 Disabled,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
106#[serde(deny_unknown_fields)]
107pub struct GlovesConfigFile {
108 pub version: u32,
110 #[serde(default)]
112 pub paths: ConfigPathsFile,
113 #[serde(default)]
115 pub private_paths: BTreeMap<String, String>,
116 #[serde(default)]
118 pub daemon: DaemonConfigFile,
119 #[serde(default)]
121 pub vault: VaultConfigFile,
122 #[serde(default)]
124 pub defaults: DefaultsConfigFile,
125 #[serde(default)]
127 pub agents: BTreeMap<String, AgentAccessFile>,
128 #[serde(default)]
130 pub secrets: SecretsConfigFile,
131}
132
133#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
135#[serde(deny_unknown_fields)]
136pub struct ConfigPathsFile {
137 pub root: Option<String>,
139}
140
141#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
143#[serde(deny_unknown_fields)]
144pub struct DaemonConfigFile {
145 pub bind: Option<String>,
147 pub io_timeout_seconds: Option<u64>,
149 pub request_limit_bytes: Option<usize>,
151}
152
153#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
155#[serde(deny_unknown_fields)]
156pub struct VaultConfigFile {
157 pub mode: Option<VaultMode>,
159}
160
161#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
163#[serde(deny_unknown_fields)]
164pub struct DefaultsConfigFile {
165 pub agent_id: Option<String>,
167 pub secret_ttl_days: Option<i64>,
169 pub vault_mount_ttl: Option<String>,
171 pub vault_secret_ttl_days: Option<i64>,
173 pub vault_secret_length_bytes: Option<usize>,
175}
176
177#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
179#[serde(deny_unknown_fields)]
180pub struct SecretsConfigFile {
181 #[serde(default)]
183 pub acl: BTreeMap<String, SecretAccessFile>,
184 #[serde(default)]
186 pub pipe: SecretPipePoliciesFile,
187}
188
189#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
191#[serde(deny_unknown_fields)]
192pub struct SecretAccessFile {
193 #[serde(default)]
195 pub paths: Vec<String>,
196 #[serde(default)]
198 pub operations: Vec<SecretAclOperation>,
199}
200
201#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
203#[serde(deny_unknown_fields)]
204pub struct SecretPipePoliciesFile {
205 #[serde(default)]
207 pub commands: BTreeMap<String, SecretPipeCommandPolicyFile>,
208}
209
210#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
212#[serde(deny_unknown_fields)]
213pub struct SecretPipeCommandPolicyFile {
214 #[serde(default)]
216 pub require_url: bool,
217 #[serde(default)]
219 pub url_prefixes: Vec<String>,
220}
221
222#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
224#[serde(deny_unknown_fields)]
225pub struct AgentAccessFile {
226 pub paths: Vec<String>,
228 pub operations: Vec<PathOperation>,
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
234pub struct DaemonBootstrapConfig {
235 pub bind: String,
237 pub io_timeout_seconds: u64,
239 pub request_limit_bytes: usize,
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
245pub struct VaultBootstrapConfig {
246 pub mode: VaultMode,
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
252pub struct DefaultBootstrapConfig {
253 pub agent_id: AgentId,
255 pub secret_ttl_days: i64,
257 pub vault_mount_ttl: String,
259 pub vault_secret_ttl_days: i64,
261 pub vault_secret_length_bytes: usize,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
267pub struct AgentAccessPolicy {
268 pub path_aliases: Vec<String>,
270 pub operations: Vec<PathOperation>,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
276pub struct SecretAccessPolicy {
277 pub paths: Vec<String>,
279 pub operations: Vec<SecretAclOperation>,
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
285pub struct SecretPipeCommandPolicy {
286 pub require_url: bool,
288 pub url_prefixes: Vec<String>,
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
294pub struct GlovesConfig {
295 pub source_path: PathBuf,
297 pub root: PathBuf,
299 pub private_paths: BTreeMap<String, PathBuf>,
301 pub daemon: DaemonBootstrapConfig,
303 pub vault: VaultBootstrapConfig,
305 pub defaults: DefaultBootstrapConfig,
307 pub agents: BTreeMap<String, AgentAccessPolicy>,
309 pub secret_access: BTreeMap<String, SecretAccessPolicy>,
311 pub secret_pipe_commands: BTreeMap<String, SecretPipeCommandPolicy>,
313}
314
315#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
317pub struct ResolvedAgentPathAccess {
318 pub alias: String,
320 pub path: PathBuf,
322 pub operations: Vec<PathOperation>,
324}
325
326impl GlovesConfig {
327 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 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 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 pub fn has_secret_acl(&self) -> bool {
379 !self.secret_access.is_empty()
380 }
381
382 pub fn secret_access_policy(&self, agent: &AgentId) -> Option<&SecretAccessPolicy> {
384 self.secret_access.get(agent.as_str())
385 }
386
387 pub fn secret_pipe_command_policy(&self, command: &str) -> Option<&SecretPipeCommandPolicy> {
389 self.secret_pipe_commands.get(command)
390 }
391}
392
393impl SecretAccessPolicy {
394 pub fn allows_operation(&self, operation: SecretAclOperation) -> bool {
396 self.operations.contains(&operation)
397 }
398
399 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
407pub 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
469pub 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(®ular_path, "version = 1\n").unwrap();
1774 fs::set_permissions(®ular_path, fs::Permissions::from_mode(0o666)).unwrap();
1775 assert!(validate_config_file_permissions(®ular_path, false)
1776 .unwrap_err()
1777 .to_string()
1778 .contains("must not be group/world writable"));
1779
1780 fs::set_permissions(®ular_path, fs::Permissions::from_mode(0o750)).unwrap();
1781 assert!(validate_config_file_permissions(®ular_path, true)
1782 .unwrap_err()
1783 .to_string()
1784 .contains("must be private"));
1785
1786 symlink(®ular_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}