1use std::collections::BTreeMap;
23use std::path::{Path, PathBuf};
24
25use serde::{Deserialize, Serialize};
26
27use crate::error::{Error, Result};
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct Config {
32 #[serde(default)]
34 pub activation: ActivationMode,
35 pub remote: RemoteConfig,
36 #[serde(default)]
37 pub local: LocalConfig,
38 #[serde(default)]
39 pub limits: Limits,
40 #[serde(default)]
41 pub audit: AuditConfig,
42 #[serde(default)]
43 pub guard: GuardConfig,
44 #[serde(default)]
45 pub approval: ApprovalConfig,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49#[serde(deny_unknown_fields)]
50pub struct RemoteConfig {
51 pub host: String,
55 pub workdir: String,
59 #[serde(default)]
63 pub os: Os,
64 #[serde(default = "default_shell")]
70 pub shell: Vec<String>,
71 #[serde(default = "default_connect_timeout")]
72 pub connect_timeout_secs: u64,
73 #[serde(default = "default_alive_interval")]
75 pub server_alive_interval_secs: u64,
76}
77
78pub fn default_shell() -> Vec<String> {
79 vec!["bash".into(), "-lc".into()]
80}
81
82#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "lowercase")]
85pub enum Os {
86 #[default]
87 Auto,
88 Posix,
90 Windows,
92}
93fn default_connect_timeout() -> u64 {
94 20
95}
96fn default_alive_interval() -> u64 {
97 30
98}
99
100#[derive(Debug, Clone, Default, Serialize, Deserialize)]
101#[serde(deny_unknown_fields)]
102pub struct LocalConfig {
103 #[serde(default)]
106 pub allowed_dirs: Vec<PathBuf>,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct Limits {
112 #[serde(default = "default_command_timeout")]
113 pub command_timeout_secs: u64,
114 #[serde(default = "default_max_command_timeout")]
115 pub max_command_timeout_secs: u64,
116 #[serde(default = "default_max_output_bytes")]
117 pub max_output_bytes: usize,
118 #[serde(default = "default_max_read_bytes")]
119 pub max_read_bytes: usize,
120 #[serde(default = "default_max_transfer_bytes")]
121 pub max_transfer_bytes: u64,
122 #[serde(default = "default_max_transfer_files")]
123 pub max_transfer_files: usize,
124}
125
126fn default_command_timeout() -> u64 {
127 120
128}
129fn default_max_command_timeout() -> u64 {
130 1800
131}
132fn default_max_output_bytes() -> usize {
133 100 * 1024
134}
135fn default_max_read_bytes() -> usize {
136 512 * 1024
137}
138fn default_max_transfer_bytes() -> u64 {
139 1024 * 1024 * 1024
140}
141fn default_max_transfer_files() -> usize {
142 5000
143}
144
145impl Default for Limits {
146 fn default() -> Self {
147 Self {
148 command_timeout_secs: default_command_timeout(),
149 max_command_timeout_secs: default_max_command_timeout(),
150 max_output_bytes: default_max_output_bytes(),
151 max_read_bytes: default_max_read_bytes(),
152 max_transfer_bytes: default_max_transfer_bytes(),
153 max_transfer_files: default_max_transfer_files(),
154 }
155 }
156}
157
158#[derive(Debug, Clone, Default, Serialize, Deserialize)]
159#[serde(deny_unknown_fields)]
160pub struct AuditConfig {
161 pub log_dir: Option<PathBuf>,
165}
166
167#[derive(Debug, Clone, Default, Serialize, Deserialize)]
168#[serde(deny_unknown_fields)]
169pub struct GuardConfig {
170 #[serde(default)]
173 pub deny_globs: Vec<String>,
174 #[serde(default)]
176 pub deny_content: Vec<String>,
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
182#[serde(rename_all = "lowercase")]
183pub enum ActivationMode {
184 Always,
186 #[default]
191 Project,
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub enum Source {
197 Explicit,
198 Env,
199 Project,
200 Global,
201}
202
203#[derive(Debug, Clone)]
206pub struct Loaded {
207 pub config: Config,
208 pub path: PathBuf,
209 pub source: Source,
210 pub notices: Vec<String>,
213}
214
215impl Loaded {
216 pub fn active(&self) -> bool {
217 match self.source {
218 Source::Explicit | Source::Env | Source::Project => true,
219 Source::Global => self.config.activation == ActivationMode::Always,
220 }
221 }
222}
223
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(rename_all = "lowercase")]
229pub enum ApprovalMode {
230 Ask,
233 Auto,
235 Strict,
237}
238
239impl ApprovalMode {
240 pub fn as_str(self) -> &'static str {
241 match self {
242 ApprovalMode::Ask => "ask",
243 ApprovalMode::Auto => "auto",
244 ApprovalMode::Strict => "strict",
245 }
246 }
247}
248
249pub const MUTATING_TOOLS: &[&str] = &[
251 "remote_shell",
252 "remote_write",
253 "remote_edit",
254 "upload",
255 "download",
256];
257
258pub const READ_ONLY_TOOLS: &[&str] = &[
260 "remote_read",
261 "remote_ls",
262 "remote_glob",
263 "remote_grep",
264 "local_ls",
265 "local_read",
266 "remote_info",
267];
268
269#[derive(Debug, Clone, Serialize, Deserialize)]
270#[serde(deny_unknown_fields)]
271pub struct ApprovalConfig {
272 #[serde(default = "default_approval")]
274 pub mode: ApprovalMode,
275 #[serde(default)]
277 pub tools: BTreeMap<String, ApprovalMode>,
278}
279
280fn default_approval() -> ApprovalMode {
281 ApprovalMode::Ask
282}
283
284impl Default for ApprovalConfig {
285 fn default() -> Self {
286 Self {
287 mode: default_approval(),
288 tools: BTreeMap::new(),
289 }
290 }
291}
292
293impl ApprovalConfig {
294 pub fn effective(&self) -> BTreeMap<&'static str, ApprovalMode> {
297 let mutating = match self.mode {
298 ApprovalMode::Auto => ApprovalMode::Auto,
299 ApprovalMode::Ask | ApprovalMode::Strict => ApprovalMode::Ask,
300 };
301 let read_only = match self.mode {
302 ApprovalMode::Strict => ApprovalMode::Ask,
303 ApprovalMode::Ask | ApprovalMode::Auto => ApprovalMode::Auto,
304 };
305 MUTATING_TOOLS
306 .iter()
307 .map(|t| (*t, mutating))
308 .chain(READ_ONLY_TOOLS.iter().map(|t| (*t, read_only)))
309 .map(|(t, m)| (t, self.tools.get(t).copied().unwrap_or(m)))
310 .collect()
311 }
312}
313
314impl Config {
315 pub fn example() -> &'static str {
316 EXAMPLE
317 }
318
319 pub fn load(explicit: Option<&Path>) -> Result<(Self, PathBuf)> {
322 let l = Self::load_in(None, explicit)?;
323 Ok((l.config, l.path))
324 }
325
326 pub fn load_in(cwd: Option<&Path>, explicit: Option<&Path>) -> Result<Loaded> {
329 let (path, source) = match explicit {
330 Some(p) if p.file_name() == Some(".farhand.toml".as_ref()) => {
333 (p.to_path_buf(), Source::Project)
334 }
335 Some(p) => (p.to_path_buf(), Source::Explicit),
336 None => Self::locate(cwd)?,
337 };
338 let text = std::fs::read_to_string(&path)
339 .map_err(|e| Error::Config(format!("cannot read {}: {e}", path.display())))?;
340 let base = match source {
344 Source::Global => None,
345 _ => path.parent().map(Path::to_path_buf),
346 };
347 let (config, notices) = if source == Source::Project {
348 let global = Self::global_path();
349 let mut notices = Vec::new();
350 let baseline = if global.is_file() {
351 match std::fs::read_to_string(&global)
352 .map_err(Error::from)
353 .and_then(|t| Self::parse(&t))
354 {
355 Ok(c) => Some(c),
356 Err(e) => {
357 notices.push(format!(
358 "the global config {} does not load ({e}); the project file is \
359 measured against the defaults instead",
360 global.display()
361 ));
362 None
363 }
364 }
365 } else {
366 None
367 };
368 let (config, more) = Self::parse_project(&text, base.as_deref(), baseline.as_ref())?;
369 notices.extend(more);
370 (config, notices)
371 } else {
372 (Self::parse_at(&text, base.as_deref())?, Vec::new())
373 };
374 Ok(Loaded {
375 config,
376 path,
377 source,
378 notices,
379 })
380 }
381
382 pub fn parse_project(
386 text: &str,
387 base: Option<&Path>,
388 baseline: Option<&Config>,
389 ) -> Result<(Self, Vec<String>)> {
390 let mut cfg: Config = toml::from_str(text).map_err(|e| Error::Config(e.to_string()))?;
391 let mut notices = Vec::new();
392 let requested = std::mem::take(&mut cfg.local.allowed_dirs);
393 for d in requested {
394 if d.is_absolute() || d.to_string_lossy().starts_with('~') {
395 notices.push(format!(
396 "local.allowed_dirs entry `{}` ignored: a project .farhand.toml may only \
397 allow folders inside its own directory (`\".\"`, `\"assets\"`); put other \
398 folders in the global config",
399 d.display()
400 ));
401 } else {
402 cfg.local.allowed_dirs.push(d);
403 }
404 }
405 if let Some(d) = cfg.audit.log_dir.take() {
406 notices.push(format!(
407 "audit.log_dir `{}` ignored: only the global config may move the audit log",
408 d.display()
409 ));
410 }
411 cfg.validate(base)?;
412 cfg.audit.log_dir = baseline.and_then(|b| b.audit.log_dir.clone());
413
414 let floor = baseline.map(|b| b.approval.clone()).unwrap_or_default();
415 let floor = floor.effective();
416 let mut loosened = Vec::new();
417 let mut tools = BTreeMap::new();
418 for (tool, mode) in cfg.approval.effective() {
419 let mode = if mode == ApprovalMode::Auto && floor[tool] == ApprovalMode::Ask {
420 loosened.push(tool);
421 ApprovalMode::Ask
422 } else {
423 mode
424 };
425 tools.insert(tool.to_string(), mode);
426 }
427 if !loosened.is_empty() {
428 notices.push(format!(
429 "[approval] `auto` ignored for {}: a project .farhand.toml may make approval \
430 stricter, not looser; set it in the global config",
431 loosened.join(", ")
432 ));
433 cfg.approval.tools = tools;
436 }
437
438 if let Some(b) = baseline {
440 for g in &b.guard.deny_globs {
441 if !cfg.guard.deny_globs.contains(g) {
442 cfg.guard.deny_globs.push(g.clone());
443 }
444 }
445 for r in &b.guard.deny_content {
446 if !cfg.guard.deny_content.contains(r) {
447 cfg.guard.deny_content.push(r.clone());
448 }
449 }
450 }
451 Ok((cfg, notices))
452 }
453
454 pub fn parse(text: &str) -> Result<Self> {
457 Self::parse_at(text, None)
458 }
459
460 pub fn parse_at(text: &str, base: Option<&Path>) -> Result<Self> {
463 let mut cfg: Config = toml::from_str(text).map_err(|e| Error::Config(e.to_string()))?;
464 cfg.validate(base)?;
465 Ok(cfg)
466 }
467
468 fn locate(cwd: Option<&Path>) -> Result<(PathBuf, Source)> {
469 if let Some(p) = std::env::var_os("FARHAND_CONFIG") {
470 return Ok((PathBuf::from(p), Source::Env));
471 }
472 let local = match cwd {
473 Some(d) => d.join(".farhand.toml"),
474 None => PathBuf::from(".farhand.toml"),
475 };
476 if local.is_file() {
477 return Ok((local, Source::Project));
478 }
479 let p = Self::global_path();
480 if p.is_file() {
481 return Ok((p, Source::Global));
482 }
483 Err(Error::NoConfig)
484 }
485
486 pub fn global_path() -> PathBuf {
487 xdg_dir("XDG_CONFIG_HOME", ".config")
488 .join("farhand")
489 .join("config.toml")
490 }
491
492 fn validate(&mut self, base: Option<&Path>) -> Result<()> {
493 if self.remote.host.trim().is_empty() {
494 return Err(Error::Config("remote.host is empty".into()));
495 }
496 if self.remote.host.starts_with('-') {
497 return Err(Error::Config("remote.host may not start with '-'".into()));
498 }
499 let raw = self.remote.workdir.trim();
500 let w = raw.trim_end_matches(['/', '\\']);
501 let w = if w.is_empty() && raw.starts_with('/') {
502 "/".to_string()
503 } else if is_drive_root(raw) {
504 format!("{}\\", &raw[..2])
506 } else {
507 w.to_string()
508 };
509 let windows_abs = w.len() >= 3
510 && w.as_bytes()[0].is_ascii_alphabetic()
511 && w.as_bytes()[1] == b':'
512 && (w.as_bytes()[2] == b'\\' || w.as_bytes()[2] == b'/');
513 if !(w.starts_with('/')
514 || w == "~"
515 || w.starts_with("~/")
516 || w.starts_with("~\\")
517 || windows_abs)
518 {
519 return Err(Error::Config(
520 "remote.workdir must be an absolute path (`/srv/app`, `C:\\work`) or start with `~` \
521 (the remote home)"
522 .into(),
523 ));
524 }
525 self.remote.workdir = w;
526 if self.remote.shell.is_empty() {
527 return Err(Error::Config("remote.shell is empty".into()));
528 }
529 if self.limits.command_timeout_secs == 0 || self.limits.max_command_timeout_secs == 0 {
530 return Err(Error::Config("timeouts must be positive".into()));
531 }
532 let mut dirs = Vec::with_capacity(self.local.allowed_dirs.len());
533 let home = dirs::home_dir();
534 let home_canon = home.as_deref().and_then(|h| h.canonicalize().ok());
535 for d in &self.local.allowed_dirs {
536 let mut expanded = expand_home(d);
537 if expanded
540 .components()
541 .any(|c| matches!(c, std::path::Component::ParentDir))
542 {
543 return Err(Error::Config(format!(
544 "local.allowed_dirs entry `{}` may not contain `..`",
545 d.display()
546 )));
547 }
548 if !expanded.is_absolute() {
549 let Some(base) = base else {
552 return Err(Error::Config(format!(
553 "local.allowed_dirs entry `{}` is relative; only a project \
554 .farhand.toml may use relative entries (`\".\"` for its own folder)",
555 d.display()
556 )));
557 };
558 expanded = base.join(expanded);
559 }
560 let canon = expanded.canonicalize().ok();
564 let covers_home = |h: &Path| {
565 h.starts_with(&expanded) || canon.as_deref().is_some_and(|c| h.starts_with(c))
566 };
567 if expanded.parent().is_none()
568 || home.as_deref().is_some_and(covers_home)
569 || home_canon.as_deref().is_some_and(covers_home)
570 {
571 return Err(Error::Config(format!(
572 "local.allowed_dirs may not contain `/`, the home directory or a folder \
573 above it ({}); allow a specific folder instead",
574 d.display()
575 )));
576 }
577 dirs.push(expanded);
578 }
579 self.local.allowed_dirs = dirs;
580 if let Some(d) = &self.audit.log_dir {
581 self.audit.log_dir = Some(expand_home(d));
582 }
583 for (t, m) in &self.approval.tools {
584 if t == "remote_bash" {
585 return Err(Error::Config(
586 "approval.tools: `remote_bash` was renamed to `remote_shell` in 0.2.1".into(),
587 ));
588 }
589 if !MUTATING_TOOLS.contains(&t.as_str()) && !READ_ONLY_TOOLS.contains(&t.as_str()) {
590 return Err(Error::Config(format!(
591 "approval.tools: `{t}` is not a tool (one of {}, {})",
592 MUTATING_TOOLS.join(", "),
593 READ_ONLY_TOOLS.join(", ")
594 )));
595 }
596 if *m == ApprovalMode::Strict {
597 return Err(Error::Config(format!(
598 "approval.tools.{t}: use `ask` or `auto` (`strict` is a global mode)"
599 )));
600 }
601 }
602 Ok(())
603 }
604
605 pub fn audit_dir(&self) -> PathBuf {
606 self.audit.log_dir.clone().unwrap_or_else(|| {
607 xdg_dir("XDG_DATA_HOME", ".local/share")
608 .join("farhand")
609 .join("audit")
610 })
611 }
612}
613
614fn xdg_dir(var: &str, fallback: &str) -> PathBuf {
618 if let Some(v) = std::env::var_os(var) {
619 if !v.is_empty() {
620 return PathBuf::from(v);
621 }
622 }
623 dirs::home_dir()
624 .unwrap_or_else(|| PathBuf::from("."))
625 .join(fallback)
626}
627
628fn is_drive_root(s: &str) -> bool {
631 let b = s.as_bytes();
632 (b.len() == 2 || b.len() == 3)
633 && b[0].is_ascii_alphabetic()
634 && b[1] == b':'
635 && (b.len() == 2 || b[2] == b'\\' || b[2] == b'/')
636}
637
638pub fn expand_home(p: &Path) -> PathBuf {
639 let s = p.to_string_lossy();
640 if s == "~" {
641 return dirs::home_dir().unwrap_or_else(|| p.to_path_buf());
642 }
643 if let Some(rest) = s.strip_prefix("~/") {
644 if let Some(home) = dirs::home_dir() {
645 return home.join(rest);
646 }
647 }
648 p.to_path_buf()
649}
650
651const EXAMPLE: &str = r#"# FarHand configuration.
652# As .farhand.toml in a project root, it makes that project remote.
653# As ~/.config/farhand/config.toml it is optional: only needed for
654# activation = "always" (or as the target of --config / FARHAND_CONFIG).
655
656# "project" (default): only directories that carry a .farhand.toml are
657# remote; anywhere else the agent keeps its local tools and FarHand
658# stays out of the way.
659# "always": every agent session on this machine is remote, using the
660# [remote] section below.
661# Only meaningful in this global file; a project's .farhand.toml is always
662# active in its own directory.
663# activation = "project"
664
665[remote]
666# An alias from ~/.ssh/config or user@host. Keys, ports, jump hosts and
667# agent forwarding all come from your OpenSSH config.
668host = "devbox"
669# Directory on the remote where every command starts and relative paths
670# resolve. Absolute, or `~/...` for the remote user's home. Created on
671# first connection if it does not exist.
672workdir = "~/project"
673# os = "auto" # "posix" or "windows"; detected on connect, but set it so
674# # the model knows which shell it is writing for
675# shell = ["bash", "-lc"] # Windows: ["powershell"] (default) or ["pwsh"]
676# connect_timeout_secs = 20
677# server_alive_interval_secs = 30
678
679[local]
680# The only local directories the model may list, read from, upload from or
681# download into. Leave empty to close the local filesystem completely.
682# In a project .farhand.toml, "." means this folder: drop files here to
683# upload them, and downloads land here. A project file may only name this
684# folder or folders inside it; absolute and `~` entries belong in the
685# global config. Never allow your home directory or anything above it.
686allowed_dirs = ["."]
687
688[limits]
689# command_timeout_secs = 120
690# max_command_timeout_secs = 1800
691# max_output_bytes = 102400
692# max_read_bytes = 524288
693# max_transfer_bytes = 1073741824
694# max_transfer_files = 5000
695
696[audit]
697# Global config only; a project file cannot move the log.
698# log_dir = "~/.local/share/farhand/audit"
699
700[guard]
701# Extra globs that may never leave this machine (added to the built-in list).
702# deny_globs = ["*.internal"]
703# Extra regexes that mark content as secret.
704# deny_content = ["ACME-[0-9]{12}"]
705
706[approval]
707# "ask": the agent prompts you before remote_shell, remote_write,
708# remote_edit, upload and download; read-only tools run freely.
709# "auto": nothing prompts; the audit log is your record.
710# "strict": every tool prompts, reads and searches included.
711# A project .farhand.toml can make this stricter than the global config,
712# never looser: `auto` there only holds where the global config allows it.
713mode = "ask"
714# Per-tool overrides (`ask` or `auto`), any tool name:
715# tools = { remote_write = "auto", remote_edit = "auto", remote_read = "ask" }
716"#;
717
718#[cfg(test)]
719mod tests {
720 use super::*;
721
722 #[test]
723 fn example_parses() {
724 let cfg = Config::parse_at(Config::example(), Some(Path::new("/proj"))).unwrap();
727 assert_eq!(cfg.remote.host, "devbox");
728 assert_eq!(cfg.remote.shell, vec!["bash", "-lc"]);
729 assert_eq!(cfg.local.allowed_dirs, vec![PathBuf::from("/proj")]);
730 assert!(Config::parse(Config::example()).is_err());
731 }
732
733 #[test]
734 fn rejects_home_and_root_as_allowed_dirs() {
735 for d in ["/", "~"] {
736 let err = Config::parse(&format!(
737 "[remote]\nhost='h'\nworkdir='/w'\n[local]\nallowed_dirs=['{d}']"
738 ))
739 .unwrap_err();
740 assert!(err.to_string().contains("home directory"), "{d}: {err}");
741 }
742 assert!(
743 Config::parse("[remote]\nhost='h'\nworkdir='/w'\n[local]\nallowed_dirs=['~/out']")
744 .is_ok()
745 );
746 }
747
748 #[test]
749 fn relative_allowed_dirs_resolve_against_the_project_file() {
750 let text = "[remote]\nhost='h'\nworkdir='/w'\n[local]\nallowed_dirs=['.', 'assets']";
751 let err = Config::parse(text).unwrap_err();
752 assert!(err.to_string().contains("relative"), "{err}");
753 let cfg = Config::parse_at(text, Some(Path::new("/proj"))).unwrap();
754 assert_eq!(
755 cfg.local.allowed_dirs,
756 vec![PathBuf::from("/proj"), PathBuf::from("/proj/assets")]
757 );
758 let err = Config::parse_at(
759 "[remote]\nhost='h'\nworkdir='/w'\n[local]\nallowed_dirs=['../x']",
760 Some(Path::new("/proj")),
761 )
762 .unwrap_err();
763 assert!(err.to_string().contains(".."), "{err}");
764 }
765
766 #[test]
767 fn old_tool_name_points_at_the_new_one() {
768 let err = Config::parse(
769 "[remote]\nhost='h'\nworkdir='/'\n[approval]\ntools={ remote_bash='ask' }",
770 )
771 .unwrap_err();
772 assert!(
773 err.to_string().contains("renamed to `remote_shell`"),
774 "{err}"
775 );
776 }
777
778 #[test]
779 fn rejects_relative_workdir() {
780 let err = Config::parse("[remote]\nhost='h'\nworkdir='rel'").unwrap_err();
781 assert!(err.to_string().contains("absolute"));
782 let cfg = Config::parse("[remote]\nhost='h'\nworkdir='~/'").unwrap();
783 assert_eq!(cfg.remote.workdir, "~");
784 let cfg = Config::parse("[remote]\nhost='h'\nworkdir='/srv/app/'").unwrap();
785 assert_eq!(cfg.remote.workdir, "/srv/app");
786 let cfg = Config::parse("[remote]\nhost='h'\nworkdir='C:\\work\\'").unwrap();
787 assert_eq!(cfg.remote.workdir, "C:\\work");
788 let cfg = Config::parse("[remote]\nhost='h'\nworkdir='D:/'").unwrap();
789 assert_eq!(cfg.remote.workdir, "D:\\");
790 let cfg = Config::parse("[remote]\nhost='h'\nworkdir='C:'").unwrap();
791 assert_eq!(cfg.remote.workdir, "C:\\");
792 assert!(Config::parse("[remote]\nhost='h'\nworkdir='work'").is_err());
793 let cfg = Config::parse("[remote]\nhost='h'\nworkdir='/'\nos='windows'").unwrap();
794 assert_eq!(cfg.remote.os, Os::Windows);
795 }
796
797 #[test]
798 fn approval_overrides_apply() {
799 let cfg = Config::parse(
800 "[remote]\nhost='h'\nworkdir='/'\n[approval]\nmode='auto'\ntools={ remote_shell='ask' }",
801 )
802 .unwrap();
803 let e = cfg.approval.effective();
804 assert_eq!(e["remote_shell"], ApprovalMode::Ask);
805 assert_eq!(e["remote_write"], ApprovalMode::Auto);
806 assert_eq!(e["remote_read"], ApprovalMode::Auto);
807 assert_eq!(e.len(), MUTATING_TOOLS.len() + READ_ONLY_TOOLS.len());
808
809 let strict = Config::parse(
810 "[remote]\nhost='h'\nworkdir='/'\n[approval]\nmode='strict'\ntools={ remote_info='auto' }",
811 )
812 .unwrap();
813 let e = strict.approval.effective();
814 assert_eq!(e["remote_read"], ApprovalMode::Ask);
815 assert_eq!(e["remote_shell"], ApprovalMode::Ask);
816 assert_eq!(e["remote_info"], ApprovalMode::Auto);
817
818 let default = Config::parse("[remote]\nhost='h'\nworkdir='/'").unwrap();
819 assert_eq!(
820 default.approval.effective()["remote_read"],
821 ApprovalMode::Auto
822 );
823 assert!(Config::parse(
824 "[remote]\nhost='h'\nworkdir='/'\n[approval]\ntools={ nope='auto' }"
825 )
826 .is_err());
827 assert!(Config::parse(
828 "[remote]\nhost='h'\nworkdir='/'\n[approval]\ntools={ upload='strict' }"
829 )
830 .is_err());
831 }
832
833 #[test]
834 fn activation_depends_on_source() {
835 let mk = |activation: &str, source: Source| Loaded {
836 config: Config::parse(&format!(
837 "activation='{activation}'\n[remote]\nhost='h'\nworkdir='/'"
838 ))
839 .unwrap(),
840 path: PathBuf::new(),
841 source,
842 notices: Vec::new(),
843 };
844 assert!(mk("always", Source::Global).active());
845 assert!(!mk("project", Source::Global).active());
846 assert!(mk("project", Source::Project).active());
847 assert!(mk("project", Source::Explicit).active());
848 assert_eq!(
849 Config::parse("[remote]\nhost='h'\nworkdir='/'")
850 .unwrap()
851 .activation,
852 ActivationMode::Project
853 );
854 }
855
856 #[test]
857 fn allowed_dirs_refuse_home_ancestors_and_dotdot() {
858 let home = dirs::home_dir().unwrap();
859 let parent = home.parent().unwrap().display().to_string();
860 for d in [parent.as_str(), "~/..", "~/../x", "/tmp/../etc"] {
861 let err = Config::parse(&format!(
862 "[remote]\nhost='h'\nworkdir='/w'\n[local]\nallowed_dirs=['{d}']"
863 ))
864 .unwrap_err();
865 assert!(err.to_string().contains("allowed_dirs"), "{d}: {err}");
866 }
867 }
868
869 fn project(text: &str, baseline: Option<&str>) -> (Config, Vec<String>) {
870 let baseline = baseline.map(|b| Config::parse(b).unwrap());
871 Config::parse_project(text, Some(Path::new("/proj")), baseline.as_ref()).unwrap()
872 }
873
874 #[test]
875 fn project_file_cannot_loosen_approval() {
876 let (cfg, notes) = project(
877 "[remote]\nhost='h'\nworkdir='/'\n[approval]\nmode='auto'",
878 None,
879 );
880 let e = cfg.approval.effective();
881 assert_eq!(e["remote_shell"], ApprovalMode::Ask);
882 assert_eq!(e["upload"], ApprovalMode::Ask);
883 assert_eq!(e["remote_read"], ApprovalMode::Auto);
884 assert_eq!(notes.len(), 1);
885 assert!(notes[0].contains("remote_shell"), "{notes:?}");
886
887 let global = "[remote]\nhost='g'\nworkdir='/'\n[approval]\nmode='auto'";
889 let (cfg, notes) = project(
890 "[remote]\nhost='h'\nworkdir='/'\n[approval]\nmode='auto'\ntools={ upload='ask' }",
891 Some(global),
892 );
893 assert!(notes.is_empty(), "{notes:?}");
894 assert_eq!(cfg.approval.effective()["remote_shell"], ApprovalMode::Auto);
895 assert_eq!(cfg.approval.effective()["upload"], ApprovalMode::Ask);
896
897 let strict = "[remote]\nhost='g'\nworkdir='/'\n[approval]\nmode='strict'";
899 let (cfg, notes) = project("[remote]\nhost='h'\nworkdir='/'", Some(strict));
900 assert_eq!(cfg.approval.effective()["remote_read"], ApprovalMode::Ask);
901 assert!(notes[0].contains("remote_read"), "{notes:?}");
902 }
903
904 #[test]
905 fn project_file_keeps_to_its_own_folder_and_the_global_log() {
906 let (cfg, notes) = project(
907 "[remote]\nhost='h'\nworkdir='/'\n[local]\nallowed_dirs=['.', '/Users', '~/x']\n\
908 [audit]\nlog_dir='/dev'",
909 Some("[remote]\nhost='g'\nworkdir='/'\n[audit]\nlog_dir='/var/log/fh'\n[guard]\ndeny_globs=['*.corp']"),
910 );
911 assert_eq!(cfg.local.allowed_dirs, vec![PathBuf::from("/proj")]);
912 assert_eq!(cfg.audit.log_dir, Some(PathBuf::from("/var/log/fh")));
913 assert_eq!(cfg.guard.deny_globs, vec!["*.corp".to_string()]);
914 assert_eq!(notes.len(), 3, "{notes:?}");
915 let err = Config::parse_project(
916 "[remote]\nhost='h'\nworkdir='/'\n[local]\nallowed_dirs=['../x']",
917 Some(Path::new("/proj")),
918 None,
919 )
920 .unwrap_err();
921 assert!(err.to_string().contains(".."), "{err}");
922 }
923
924 #[test]
925 fn a_farhand_toml_passed_by_path_is_still_a_project_file() {
926 let d = tempfile::tempdir().unwrap();
927 let p = d.path().join(".farhand.toml");
928 std::fs::write(
929 &p,
930 "[remote]\nhost='h'\nworkdir='/'\n[audit]\nlog_dir='/dev'",
931 )
932 .unwrap();
933 let l = Config::load_in(None, Some(&p)).unwrap();
934 assert_eq!(l.source, Source::Project);
935 assert!(
936 l.notices.iter().any(|n| n.contains("log_dir")),
937 "{:?}",
938 l.notices
939 );
940 let other = d.path().join("fh.toml");
941 std::fs::copy(&p, &other).unwrap();
942 let l = Config::load_in(None, Some(&other)).unwrap();
943 assert_eq!(l.source, Source::Explicit);
944 assert_eq!(l.config.audit.log_dir, Some(PathBuf::from("/dev")));
945 }
946
947 #[test]
948 fn rejects_unknown_fields() {
949 assert!(Config::parse("[remote]\nhost='h'\nworkdir='/'\nbogus=1").is_err());
950 }
951}