Skip to main content

farhand_core/
config.rs

1//! Configuration: where the remote is, what the local side may touch, and the
2//! limits every operation runs under.
3//!
4//! Resolution order (first hit wins):
5//! 1. an explicit path (`--config`),
6//! 2. `$FARHAND_CONFIG`,
7//! 3. `.farhand.toml` in the working directory,
8//! 4. `~/.config/farhand/config.toml`.
9//!
10//! Whether FarHand is *active* in a directory — takes the agent's local
11//! tools away and puts its remote ones in — depends on where the config
12//! came from: a project file, an explicit path or the environment always
13//! activate; the global file activates only when its `activation` is
14//! `"always"` rather than the default `"project"`.
15
16use std::collections::BTreeMap;
17use std::path::{Path, PathBuf};
18
19use serde::{Deserialize, Serialize};
20
21use crate::error::{Error, Result};
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(deny_unknown_fields)]
25pub struct Config {
26    /// When the global config alone should make a session remote.
27    #[serde(default)]
28    pub activation: ActivationMode,
29    pub remote: RemoteConfig,
30    #[serde(default)]
31    pub local: LocalConfig,
32    #[serde(default)]
33    pub limits: Limits,
34    #[serde(default)]
35    pub audit: AuditConfig,
36    #[serde(default)]
37    pub guard: GuardConfig,
38    #[serde(default)]
39    pub approval: ApprovalConfig,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct RemoteConfig {
45    /// SSH destination: an alias from `~/.ssh/config`, or `user@host`.
46    /// Authentication, jump hosts, ports and keys all come from the user's
47    /// own OpenSSH configuration; FarHand never handles credentials itself.
48    pub host: String,
49    /// Directory relative paths resolve against and commands start in.
50    /// Absolute (`/srv/app`, `C:\\work`), or `~` / `~/...` for the remote
51    /// user's home.
52    pub workdir: String,
53    /// What the remote runs. Detected on connect from the SFTP root, so
54    /// `auto` is fine; set it explicitly so the model is told before the
55    /// first command which shell it is writing for.
56    #[serde(default)]
57    pub os: Os,
58    /// Shell that runs every command, as argv. On a POSIX remote the
59    /// command text is appended as one final argument, so the last element
60    /// must accept a script (`-c`, `-lc`, ...). On Windows only the first
61    /// element is used, as the PowerShell executable (`powershell.exe` by
62    /// default; set `["pwsh"]` for PowerShell 7).
63    #[serde(default = "default_shell")]
64    pub shell: Vec<String>,
65    #[serde(default = "default_connect_timeout")]
66    pub connect_timeout_secs: u64,
67    /// Passed to ssh as `ServerAliveInterval`; keeps NAT tables warm.
68    #[serde(default = "default_alive_interval")]
69    pub server_alive_interval_secs: u64,
70}
71
72pub fn default_shell() -> Vec<String> {
73    vec!["bash".into(), "-lc".into()]
74}
75
76/// The remote operating system family.
77#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "lowercase")]
79pub enum Os {
80    #[default]
81    Auto,
82    /// Linux, macOS, BSD: a POSIX shell, `timeout(1)`, `base64(1)`.
83    Posix,
84    /// Windows with OpenSSH Server: commands run in PowerShell.
85    Windows,
86}
87fn default_connect_timeout() -> u64 {
88    20
89}
90fn default_alive_interval() -> u64 {
91    30
92}
93
94#[derive(Debug, Clone, Default, Serialize, Deserialize)]
95#[serde(deny_unknown_fields)]
96pub struct LocalConfig {
97    /// The only local directories the model may list, read, upload from or
98    /// download into. Empty means the local filesystem is fully closed.
99    #[serde(default)]
100    pub allowed_dirs: Vec<PathBuf>,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104#[serde(deny_unknown_fields)]
105pub struct Limits {
106    #[serde(default = "default_command_timeout")]
107    pub command_timeout_secs: u64,
108    #[serde(default = "default_max_command_timeout")]
109    pub max_command_timeout_secs: u64,
110    #[serde(default = "default_max_output_bytes")]
111    pub max_output_bytes: usize,
112    #[serde(default = "default_max_read_bytes")]
113    pub max_read_bytes: usize,
114    #[serde(default = "default_max_transfer_bytes")]
115    pub max_transfer_bytes: u64,
116    #[serde(default = "default_max_transfer_files")]
117    pub max_transfer_files: usize,
118}
119
120fn default_command_timeout() -> u64 {
121    120
122}
123fn default_max_command_timeout() -> u64 {
124    1800
125}
126fn default_max_output_bytes() -> usize {
127    100 * 1024
128}
129fn default_max_read_bytes() -> usize {
130    512 * 1024
131}
132fn default_max_transfer_bytes() -> u64 {
133    1024 * 1024 * 1024
134}
135fn default_max_transfer_files() -> usize {
136    5000
137}
138
139impl Default for Limits {
140    fn default() -> Self {
141        Self {
142            command_timeout_secs: default_command_timeout(),
143            max_command_timeout_secs: default_max_command_timeout(),
144            max_output_bytes: default_max_output_bytes(),
145            max_read_bytes: default_max_read_bytes(),
146            max_transfer_bytes: default_max_transfer_bytes(),
147            max_transfer_files: default_max_transfer_files(),
148        }
149    }
150}
151
152#[derive(Debug, Clone, Default, Serialize, Deserialize)]
153#[serde(deny_unknown_fields)]
154pub struct AuditConfig {
155    /// Directory for the JSONL audit log. Defaults to
156    /// `~/.local/share/farhand/audit`. There is deliberately no way to turn
157    /// the log off: every remote action is recorded.
158    pub log_dir: Option<PathBuf>,
159}
160
161#[derive(Debug, Clone, Default, Serialize, Deserialize)]
162#[serde(deny_unknown_fields)]
163pub struct GuardConfig {
164    /// Extra glob patterns (matched against the full local path and the
165    /// basename) that may never be uploaded. Added to the built-in list.
166    #[serde(default)]
167    pub deny_globs: Vec<String>,
168    /// Extra regular expressions that mark content as secret.
169    #[serde(default)]
170    pub deny_content: Vec<String>,
171}
172
173/// Whether the global config turns every session remote, or only the
174/// projects that carry their own `.farhand.toml`.
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
176#[serde(rename_all = "lowercase")]
177pub enum ActivationMode {
178    /// Every session is remote.
179    Always,
180    /// Only directories with a `.farhand.toml` are remote; everywhere else
181    /// the agent keeps its local tools and FarHand offers none. The
182    /// default: installing FarHand must not take a machine's local tools
183    /// away by surprise.
184    #[default]
185    Project,
186}
187
188/// Where a loaded config came from.
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum Source {
191    Explicit,
192    Env,
193    Project,
194    Global,
195}
196
197/// A config together with where it was found and whether it activates
198/// FarHand in the directory it was resolved for.
199#[derive(Debug, Clone)]
200pub struct Loaded {
201    pub config: Config,
202    pub path: PathBuf,
203    pub source: Source,
204}
205
206impl Loaded {
207    pub fn active(&self) -> bool {
208        match self.source {
209            Source::Explicit | Source::Env | Source::Project => true,
210            Source::Global => self.config.activation == ActivationMode::Always,
211        }
212    }
213}
214
215/// Whether an agent should stop and ask the user before a mutating tool
216/// runs. FarHand itself has no UI; this is the one knob every agent entry
217/// translates into that agent's own permission system.
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(rename_all = "lowercase")]
220pub enum ApprovalMode {
221    /// Prompt before every mutating tool; read-only tools run freely.
222    /// The default.
223    Ask,
224    /// Run everything without prompting; the audit log is the only record.
225    Auto,
226    /// Prompt before every tool, read-only ones included.
227    Strict,
228}
229
230impl ApprovalMode {
231    pub fn as_str(self) -> &'static str {
232        match self {
233            ApprovalMode::Ask => "ask",
234            ApprovalMode::Auto => "auto",
235            ApprovalMode::Strict => "strict",
236        }
237    }
238}
239
240/// The tools that change something on the remote or in the outbox.
241pub const MUTATING_TOOLS: &[&str] = &[
242    "remote_shell",
243    "remote_write",
244    "remote_edit",
245    "upload",
246    "download",
247];
248
249/// The tools that only look.
250pub const READ_ONLY_TOOLS: &[&str] = &[
251    "remote_read",
252    "remote_ls",
253    "remote_glob",
254    "remote_grep",
255    "local_ls",
256    "local_read",
257    "remote_info",
258];
259
260#[derive(Debug, Clone, Serialize, Deserialize)]
261#[serde(deny_unknown_fields)]
262pub struct ApprovalConfig {
263    /// Default for every mutating tool.
264    #[serde(default = "default_approval")]
265    pub mode: ApprovalMode,
266    /// Per-tool overrides, keyed by tool name; `ask` or `auto` only.
267    #[serde(default)]
268    pub tools: BTreeMap<String, ApprovalMode>,
269}
270
271fn default_approval() -> ApprovalMode {
272    ApprovalMode::Ask
273}
274
275impl Default for ApprovalConfig {
276    fn default() -> Self {
277        Self {
278            mode: default_approval(),
279            tools: BTreeMap::new(),
280        }
281    }
282}
283
284impl ApprovalConfig {
285    /// For every tool, whether it prompts (`Ask`) or runs (`Auto`),
286    /// overrides applied. `Strict` never appears in the result.
287    pub fn effective(&self) -> BTreeMap<&'static str, ApprovalMode> {
288        let mutating = match self.mode {
289            ApprovalMode::Auto => ApprovalMode::Auto,
290            ApprovalMode::Ask | ApprovalMode::Strict => ApprovalMode::Ask,
291        };
292        let read_only = match self.mode {
293            ApprovalMode::Strict => ApprovalMode::Ask,
294            ApprovalMode::Ask | ApprovalMode::Auto => ApprovalMode::Auto,
295        };
296        MUTATING_TOOLS
297            .iter()
298            .map(|t| (*t, mutating))
299            .chain(READ_ONLY_TOOLS.iter().map(|t| (*t, read_only)))
300            .map(|(t, m)| (t, self.tools.get(t).copied().unwrap_or(m)))
301            .collect()
302    }
303}
304
305impl Config {
306    pub fn example() -> &'static str {
307        EXAMPLE
308    }
309
310    /// Load following the resolution order in the module docs, with the
311    /// project file looked for in the process's working directory.
312    pub fn load(explicit: Option<&Path>) -> Result<(Self, PathBuf)> {
313        let l = Self::load_in(None, explicit)?;
314        Ok((l.config, l.path))
315    }
316
317    /// Load following the resolution order, with the project file looked
318    /// for in `cwd` (default: the process's working directory).
319    pub fn load_in(cwd: Option<&Path>, explicit: Option<&Path>) -> Result<Loaded> {
320        let (path, source) = match explicit {
321            Some(p) => (p.to_path_buf(), Source::Explicit),
322            None => Self::locate(cwd)?,
323        };
324        let text = std::fs::read_to_string(&path)
325            .map_err(|e| Error::Config(format!("cannot read {}: {e}", path.display())))?;
326        // Relative `allowed_dirs` entries are resolved against the file's own
327        // directory, but only for a per-project file: the global config
328        // belongs to no directory, so `"."` would mean nothing there.
329        let base = match source {
330            Source::Global => None,
331            _ => path.parent().map(Path::to_path_buf),
332        };
333        let config = Self::parse_at(&text, base.as_deref())?;
334        Ok(Loaded {
335            config,
336            path,
337            source,
338        })
339    }
340
341    /// Parse a config that belongs to no directory: `allowed_dirs` entries
342    /// must be absolute or `~/...`.
343    pub fn parse(text: &str) -> Result<Self> {
344        Self::parse_at(text, None)
345    }
346
347    /// Parse a config file that lives in `base`; relative `allowed_dirs`
348    /// entries (`"."`, `"assets"`) are resolved against it.
349    pub fn parse_at(text: &str, base: Option<&Path>) -> Result<Self> {
350        let mut cfg: Config = toml::from_str(text).map_err(|e| Error::Config(e.to_string()))?;
351        cfg.validate(base)?;
352        Ok(cfg)
353    }
354
355    fn locate(cwd: Option<&Path>) -> Result<(PathBuf, Source)> {
356        if let Some(p) = std::env::var_os("FARHAND_CONFIG") {
357            return Ok((PathBuf::from(p), Source::Env));
358        }
359        let local = match cwd {
360            Some(d) => d.join(".farhand.toml"),
361            None => PathBuf::from(".farhand.toml"),
362        };
363        if local.is_file() {
364            return Ok((local, Source::Project));
365        }
366        let p = Self::global_path();
367        if p.is_file() {
368            return Ok((p, Source::Global));
369        }
370        Err(Error::NoConfig)
371    }
372
373    pub fn global_path() -> PathBuf {
374        xdg_dir("XDG_CONFIG_HOME", ".config")
375            .join("farhand")
376            .join("config.toml")
377    }
378
379    fn validate(&mut self, base: Option<&Path>) -> Result<()> {
380        if self.remote.host.trim().is_empty() {
381            return Err(Error::Config("remote.host is empty".into()));
382        }
383        if self.remote.host.starts_with('-') {
384            return Err(Error::Config("remote.host may not start with '-'".into()));
385        }
386        let raw = self.remote.workdir.trim();
387        let w = raw.trim_end_matches(['/', '\\']);
388        let w = if w.is_empty() && raw.starts_with('/') {
389            "/".to_string()
390        } else if is_drive_root(raw) {
391            // `C:\` keeps its separator; `C:` alone would be drive-relative.
392            format!("{}\\", &raw[..2])
393        } else {
394            w.to_string()
395        };
396        let windows_abs = w.len() >= 3
397            && w.as_bytes()[0].is_ascii_alphabetic()
398            && w.as_bytes()[1] == b':'
399            && (w.as_bytes()[2] == b'\\' || w.as_bytes()[2] == b'/');
400        if !(w.starts_with('/')
401            || w == "~"
402            || w.starts_with("~/")
403            || w.starts_with("~\\")
404            || windows_abs)
405        {
406            return Err(Error::Config(
407                "remote.workdir must be an absolute path (`/srv/app`, `C:\\work`) or start with `~` \
408                 (the remote home)"
409                    .into(),
410            ));
411        }
412        self.remote.workdir = w;
413        if self.remote.shell.is_empty() {
414            return Err(Error::Config("remote.shell is empty".into()));
415        }
416        if self.limits.command_timeout_secs == 0 || self.limits.max_command_timeout_secs == 0 {
417            return Err(Error::Config("timeouts must be positive".into()));
418        }
419        let mut dirs = Vec::with_capacity(self.local.allowed_dirs.len());
420        let home = dirs::home_dir();
421        for d in &self.local.allowed_dirs {
422            let mut expanded = expand_home(d);
423            if !expanded.is_absolute() {
424                // A relative entry names the project folder itself or
425                // something inside it; `..` would let a config that arrived
426                // with a repository reach above its own directory.
427                let Some(base) = base else {
428                    return Err(Error::Config(format!(
429                        "local.allowed_dirs entry `{}` is relative; only a project \
430                         .farhand.toml may use relative entries (`\".\"` for its own folder)",
431                        d.display()
432                    )));
433                };
434                if expanded
435                    .components()
436                    .any(|c| matches!(c, std::path::Component::ParentDir))
437                {
438                    return Err(Error::Config(format!(
439                        "local.allowed_dirs entry `{}` may not contain `..`",
440                        d.display()
441                    )));
442                }
443                expanded = base.join(expanded);
444            }
445            // The allowlist is for a folder the user set aside, never the
446            // whole machine or home: that would hand every non-credential
447            // file to the model, and a project-level config could do it
448            // without the user noticing.
449            if expanded.parent().is_none() || home.as_deref() == Some(expanded.as_path()) {
450                return Err(Error::Config(format!(
451                    "local.allowed_dirs may not contain `/` or the home directory ({}); \
452                     allow a specific folder instead",
453                    d.display()
454                )));
455            }
456            dirs.push(expanded);
457        }
458        self.local.allowed_dirs = dirs;
459        if let Some(d) = &self.audit.log_dir {
460            self.audit.log_dir = Some(expand_home(d));
461        }
462        for (t, m) in &self.approval.tools {
463            if t == "remote_bash" {
464                return Err(Error::Config(
465                    "approval.tools: `remote_bash` was renamed to `remote_shell` in 0.2.1".into(),
466                ));
467            }
468            if !MUTATING_TOOLS.contains(&t.as_str()) && !READ_ONLY_TOOLS.contains(&t.as_str()) {
469                return Err(Error::Config(format!(
470                    "approval.tools: `{t}` is not a tool (one of {}, {})",
471                    MUTATING_TOOLS.join(", "),
472                    READ_ONLY_TOOLS.join(", ")
473                )));
474            }
475            if *m == ApprovalMode::Strict {
476                return Err(Error::Config(format!(
477                    "approval.tools.{t}: use `ask` or `auto` (`strict` is a global mode)"
478                )));
479            }
480        }
481        Ok(())
482    }
483
484    pub fn audit_dir(&self) -> PathBuf {
485        self.audit.log_dir.clone().unwrap_or_else(|| {
486            xdg_dir("XDG_DATA_HOME", ".local/share")
487                .join("farhand")
488                .join("audit")
489        })
490    }
491}
492
493/// `$VAR` if set, else `~/<fallback>`. The XDG layout is used on every
494/// platform so the documented paths (`~/.config/farhand`,
495/// `~/.local/share/farhand`) are the real ones on macOS too.
496fn xdg_dir(var: &str, fallback: &str) -> PathBuf {
497    if let Some(v) = std::env::var_os(var) {
498        if !v.is_empty() {
499            return PathBuf::from(v);
500        }
501    }
502    dirs::home_dir()
503        .unwrap_or_else(|| PathBuf::from("."))
504        .join(fallback)
505}
506
507/// `~` and `~/x` expand to the home directory; anything else is returned as-is.
508/// `C:\`, `C:/` or `C:` — a Windows drive root, with or without separator.
509fn is_drive_root(s: &str) -> bool {
510    let b = s.as_bytes();
511    (b.len() == 2 || b.len() == 3)
512        && b[0].is_ascii_alphabetic()
513        && b[1] == b':'
514        && (b.len() == 2 || b[2] == b'\\' || b[2] == b'/')
515}
516
517pub fn expand_home(p: &Path) -> PathBuf {
518    let s = p.to_string_lossy();
519    if s == "~" {
520        return dirs::home_dir().unwrap_or_else(|| p.to_path_buf());
521    }
522    if let Some(rest) = s.strip_prefix("~/") {
523        if let Some(home) = dirs::home_dir() {
524            return home.join(rest);
525        }
526    }
527    p.to_path_buf()
528}
529
530const EXAMPLE: &str = r#"# FarHand configuration.
531# As .farhand.toml in a project root, it makes that project remote.
532# As ~/.config/farhand/config.toml it is optional: only needed for
533# activation = "always" (or as the target of --config / FARHAND_CONFIG).
534
535# "project" (default): only directories that carry a .farhand.toml are
536#            remote; anywhere else the agent keeps its local tools and FarHand
537#            stays out of the way.
538# "always":  every agent session on this machine is remote, using the
539#            [remote] section below.
540# Only meaningful in this global file; a project's .farhand.toml is always
541# active in its own directory.
542# activation = "project"
543
544[remote]
545# An alias from ~/.ssh/config or user@host. Keys, ports, jump hosts and
546# agent forwarding all come from your OpenSSH config.
547host = "devbox"
548# Directory on the remote where every command starts and relative paths
549# resolve. Absolute, or `~/...` for the remote user's home. Created on
550# first connection if it does not exist.
551workdir = "~/project"
552# os = "auto"              # "posix" or "windows"; detected on connect, but set it so
553#                          # the model knows which shell it is writing for
554# shell = ["bash", "-lc"]  # Windows: ["powershell"] (default) or ["pwsh"]
555# connect_timeout_secs = 20
556# server_alive_interval_secs = 30
557
558[local]
559# The only local directories the model may list, read from, upload from or
560# download into. Leave empty to close the local filesystem completely.
561# In a project .farhand.toml, "." means this folder: drop files here to
562# upload them, and downloads land here. Never allow your home directory.
563allowed_dirs = ["."]
564
565[limits]
566# command_timeout_secs = 120
567# max_command_timeout_secs = 1800
568# max_output_bytes = 102400
569# max_read_bytes = 524288
570# max_transfer_bytes = 1073741824
571# max_transfer_files = 5000
572
573[audit]
574# log_dir = "~/.local/share/farhand/audit"
575
576[guard]
577# Extra globs that may never leave this machine (added to the built-in list).
578# deny_globs = ["*.internal"]
579# Extra regexes that mark content as secret.
580# deny_content = ["ACME-[0-9]{12}"]
581
582[approval]
583# "ask":    the agent prompts you before remote_shell, remote_write,
584#           remote_edit, upload and download; read-only tools run freely.
585# "auto":   nothing prompts; the audit log is your record.
586# "strict": every tool prompts, reads and searches included.
587mode = "ask"
588# Per-tool overrides (`ask` or `auto`), any tool name:
589# tools = { remote_write = "auto", remote_edit = "auto", remote_read = "ask" }
590"#;
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595
596    #[test]
597    fn example_parses() {
598        // `farhand init` writes the example as a project file; its `"."`
599        // is that file's folder, and is meaningless in the global config.
600        let cfg = Config::parse_at(Config::example(), Some(Path::new("/proj"))).unwrap();
601        assert_eq!(cfg.remote.host, "devbox");
602        assert_eq!(cfg.remote.shell, vec!["bash", "-lc"]);
603        assert_eq!(cfg.local.allowed_dirs, vec![PathBuf::from("/proj")]);
604        assert!(Config::parse(Config::example()).is_err());
605    }
606
607    #[test]
608    fn rejects_home_and_root_as_allowed_dirs() {
609        for d in ["/", "~"] {
610            let err = Config::parse(&format!(
611                "[remote]\nhost='h'\nworkdir='/w'\n[local]\nallowed_dirs=['{d}']"
612            ))
613            .unwrap_err();
614            assert!(err.to_string().contains("home directory"), "{d}: {err}");
615        }
616        assert!(
617            Config::parse("[remote]\nhost='h'\nworkdir='/w'\n[local]\nallowed_dirs=['~/out']")
618                .is_ok()
619        );
620    }
621
622    #[test]
623    fn relative_allowed_dirs_resolve_against_the_project_file() {
624        let text = "[remote]\nhost='h'\nworkdir='/w'\n[local]\nallowed_dirs=['.', 'assets']";
625        let err = Config::parse(text).unwrap_err();
626        assert!(err.to_string().contains("relative"), "{err}");
627        let cfg = Config::parse_at(text, Some(Path::new("/proj"))).unwrap();
628        assert_eq!(
629            cfg.local.allowed_dirs,
630            vec![PathBuf::from("/proj"), PathBuf::from("/proj/assets")]
631        );
632        let err = Config::parse_at(
633            "[remote]\nhost='h'\nworkdir='/w'\n[local]\nallowed_dirs=['../x']",
634            Some(Path::new("/proj")),
635        )
636        .unwrap_err();
637        assert!(err.to_string().contains(".."), "{err}");
638    }
639
640    #[test]
641    fn old_tool_name_points_at_the_new_one() {
642        let err = Config::parse(
643            "[remote]\nhost='h'\nworkdir='/'\n[approval]\ntools={ remote_bash='ask' }",
644        )
645        .unwrap_err();
646        assert!(
647            err.to_string().contains("renamed to `remote_shell`"),
648            "{err}"
649        );
650    }
651
652    #[test]
653    fn rejects_relative_workdir() {
654        let err = Config::parse("[remote]\nhost='h'\nworkdir='rel'").unwrap_err();
655        assert!(err.to_string().contains("absolute"));
656        let cfg = Config::parse("[remote]\nhost='h'\nworkdir='~/'").unwrap();
657        assert_eq!(cfg.remote.workdir, "~");
658        let cfg = Config::parse("[remote]\nhost='h'\nworkdir='/srv/app/'").unwrap();
659        assert_eq!(cfg.remote.workdir, "/srv/app");
660        let cfg = Config::parse("[remote]\nhost='h'\nworkdir='C:\\work\\'").unwrap();
661        assert_eq!(cfg.remote.workdir, "C:\\work");
662        let cfg = Config::parse("[remote]\nhost='h'\nworkdir='D:/'").unwrap();
663        assert_eq!(cfg.remote.workdir, "D:\\");
664        let cfg = Config::parse("[remote]\nhost='h'\nworkdir='C:'").unwrap();
665        assert_eq!(cfg.remote.workdir, "C:\\");
666        assert!(Config::parse("[remote]\nhost='h'\nworkdir='work'").is_err());
667        let cfg = Config::parse("[remote]\nhost='h'\nworkdir='/'\nos='windows'").unwrap();
668        assert_eq!(cfg.remote.os, Os::Windows);
669    }
670
671    #[test]
672    fn approval_overrides_apply() {
673        let cfg = Config::parse(
674            "[remote]\nhost='h'\nworkdir='/'\n[approval]\nmode='auto'\ntools={ remote_shell='ask' }",
675        )
676        .unwrap();
677        let e = cfg.approval.effective();
678        assert_eq!(e["remote_shell"], ApprovalMode::Ask);
679        assert_eq!(e["remote_write"], ApprovalMode::Auto);
680        assert_eq!(e["remote_read"], ApprovalMode::Auto);
681        assert_eq!(e.len(), MUTATING_TOOLS.len() + READ_ONLY_TOOLS.len());
682
683        let strict = Config::parse(
684            "[remote]\nhost='h'\nworkdir='/'\n[approval]\nmode='strict'\ntools={ remote_info='auto' }",
685        )
686        .unwrap();
687        let e = strict.approval.effective();
688        assert_eq!(e["remote_read"], ApprovalMode::Ask);
689        assert_eq!(e["remote_shell"], ApprovalMode::Ask);
690        assert_eq!(e["remote_info"], ApprovalMode::Auto);
691
692        let default = Config::parse("[remote]\nhost='h'\nworkdir='/'").unwrap();
693        assert_eq!(
694            default.approval.effective()["remote_read"],
695            ApprovalMode::Auto
696        );
697        assert!(Config::parse(
698            "[remote]\nhost='h'\nworkdir='/'\n[approval]\ntools={ nope='auto' }"
699        )
700        .is_err());
701        assert!(Config::parse(
702            "[remote]\nhost='h'\nworkdir='/'\n[approval]\ntools={ upload='strict' }"
703        )
704        .is_err());
705    }
706
707    #[test]
708    fn activation_depends_on_source() {
709        let mk = |activation: &str, source: Source| Loaded {
710            config: Config::parse(&format!(
711                "activation='{activation}'\n[remote]\nhost='h'\nworkdir='/'"
712            ))
713            .unwrap(),
714            path: PathBuf::new(),
715            source,
716        };
717        assert!(mk("always", Source::Global).active());
718        assert!(!mk("project", Source::Global).active());
719        assert!(mk("project", Source::Project).active());
720        assert!(mk("project", Source::Explicit).active());
721        assert_eq!(
722            Config::parse("[remote]\nhost='h'\nworkdir='/'")
723                .unwrap()
724                .activation,
725            ActivationMode::Project
726        );
727    }
728
729    #[test]
730    fn rejects_unknown_fields() {
731        assert!(Config::parse("[remote]\nhost='h'\nworkdir='/'\nbogus=1").is_err());
732    }
733}