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//!
16//! A project file (any file named `.farhand.toml`, however it was found)
17//! can arrive with a cloned repository, so it may only tighten what the
18//! global config allows: it cannot loosen `[approval]`, allow local
19//! folders outside its own directory, or move the audit log. Such settings
20//! are ignored and reported in [`Loaded::notices`].
21
22use 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    /// When the global config alone should make a session remote.
33    #[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    /// SSH destination: an alias from `~/.ssh/config`, or `user@host`.
52    /// Authentication, jump hosts, ports and keys all come from the user's
53    /// own OpenSSH configuration; FarHand never handles credentials itself.
54    pub host: String,
55    /// Directory relative paths resolve against and commands start in.
56    /// Absolute (`/srv/app`, `C:\\work`), or `~` / `~/...` for the remote
57    /// user's home.
58    pub workdir: String,
59    /// What the remote runs. Detected on connect from the SFTP root, so
60    /// `auto` is fine; set it explicitly so the model is told before the
61    /// first command which shell it is writing for.
62    #[serde(default)]
63    pub os: Os,
64    /// Shell that runs every command, as argv. On a POSIX remote the
65    /// command text is appended as one final argument, so the last element
66    /// must accept a script (`-c`, `-lc`, ...). On Windows only the first
67    /// element is used, as the PowerShell executable (`powershell.exe` by
68    /// default; set `["pwsh"]` for PowerShell 7).
69    #[serde(default = "default_shell")]
70    pub shell: Vec<String>,
71    #[serde(default = "default_connect_timeout")]
72    pub connect_timeout_secs: u64,
73    /// Passed to ssh as `ServerAliveInterval`; keeps NAT tables warm.
74    #[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/// The remote operating system family.
83#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "lowercase")]
85pub enum Os {
86    #[default]
87    Auto,
88    /// Linux, macOS, BSD: a POSIX shell, `timeout(1)`, `base64(1)`.
89    Posix,
90    /// Windows with OpenSSH Server: commands run in PowerShell.
91    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    /// The only local directories the model may list, read, upload from or
104    /// download into. Empty means the local filesystem is fully closed.
105    #[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    /// Directory for the JSONL audit log. Defaults to
162    /// `~/.local/share/farhand/audit`. There is deliberately no way to turn
163    /// the log off: every remote action is recorded.
164    pub log_dir: Option<PathBuf>,
165}
166
167#[derive(Debug, Clone, Default, Serialize, Deserialize)]
168#[serde(deny_unknown_fields)]
169pub struct GuardConfig {
170    /// Extra glob patterns (matched against the full local path and the
171    /// basename) that may never be uploaded. Added to the built-in list.
172    #[serde(default)]
173    pub deny_globs: Vec<String>,
174    /// Extra regular expressions that mark content as secret.
175    #[serde(default)]
176    pub deny_content: Vec<String>,
177}
178
179/// Whether the global config turns every session remote, or only the
180/// projects that carry their own `.farhand.toml`.
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
182#[serde(rename_all = "lowercase")]
183pub enum ActivationMode {
184    /// Every session is remote.
185    Always,
186    /// Only directories with a `.farhand.toml` are remote; everywhere else
187    /// the agent keeps its local tools and FarHand offers none. The
188    /// default: installing FarHand must not take a machine's local tools
189    /// away by surprise.
190    #[default]
191    Project,
192}
193
194/// Where a loaded config came from.
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub enum Source {
197    Explicit,
198    Env,
199    Project,
200    Global,
201}
202
203/// A config together with where it was found and whether it activates
204/// FarHand in the directory it was resolved for.
205#[derive(Debug, Clone)]
206pub struct Loaded {
207    pub config: Config,
208    pub path: PathBuf,
209    pub source: Source,
210    /// Settings a project file tried and was not allowed to make, in words
211    /// for the user. Empty for every other source.
212    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/// Whether an agent should stop and ask the user before a mutating tool
225/// runs. FarHand itself has no UI; this is the one knob every agent entry
226/// translates into that agent's own permission system.
227#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(rename_all = "lowercase")]
229pub enum ApprovalMode {
230    /// Prompt before every mutating tool; read-only tools run freely.
231    /// The default.
232    Ask,
233    /// Run everything without prompting; the audit log is the only record.
234    Auto,
235    /// Prompt before every tool, read-only ones included.
236    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
249/// The tools that change something on the remote or in the outbox.
250pub const MUTATING_TOOLS: &[&str] = &[
251    "remote_shell",
252    "remote_spawn",
253    "remote_kill",
254    "remote_write",
255    "remote_edit",
256    "upload",
257    "download",
258];
259
260/// The tools that only look.
261pub const READ_ONLY_TOOLS: &[&str] = &[
262    "remote_read",
263    "remote_ls",
264    "remote_glob",
265    "remote_grep",
266    "remote_jobs",
267    "local_ls",
268    "local_read",
269    "remote_info",
270];
271
272#[derive(Debug, Clone, Serialize, Deserialize)]
273#[serde(deny_unknown_fields)]
274pub struct ApprovalConfig {
275    /// Default for every mutating tool.
276    #[serde(default = "default_approval")]
277    pub mode: ApprovalMode,
278    /// Per-tool overrides, keyed by tool name; `ask` or `auto` only.
279    #[serde(default)]
280    pub tools: BTreeMap<String, ApprovalMode>,
281}
282
283fn default_approval() -> ApprovalMode {
284    ApprovalMode::Ask
285}
286
287impl Default for ApprovalConfig {
288    fn default() -> Self {
289        Self {
290            mode: default_approval(),
291            tools: BTreeMap::new(),
292        }
293    }
294}
295
296impl ApprovalConfig {
297    /// For every tool, whether it prompts (`Ask`) or runs (`Auto`),
298    /// overrides applied. `Strict` never appears in the result.
299    pub fn effective(&self) -> BTreeMap<&'static str, ApprovalMode> {
300        let mutating = match self.mode {
301            ApprovalMode::Auto => ApprovalMode::Auto,
302            ApprovalMode::Ask | ApprovalMode::Strict => ApprovalMode::Ask,
303        };
304        let read_only = match self.mode {
305            ApprovalMode::Strict => ApprovalMode::Ask,
306            ApprovalMode::Ask | ApprovalMode::Auto => ApprovalMode::Auto,
307        };
308        MUTATING_TOOLS
309            .iter()
310            .map(|t| (*t, mutating))
311            .chain(READ_ONLY_TOOLS.iter().map(|t| (*t, read_only)))
312            .map(|(t, m)| (t, self.tools.get(t).copied().unwrap_or(m)))
313            .collect()
314    }
315}
316
317impl Config {
318    pub fn example() -> &'static str {
319        EXAMPLE
320    }
321
322    /// Load following the resolution order in the module docs, with the
323    /// project file looked for in the process's working directory.
324    pub fn load(explicit: Option<&Path>) -> Result<(Self, PathBuf)> {
325        let l = Self::load_in(None, explicit)?;
326        Ok((l.config, l.path))
327    }
328
329    /// Load following the resolution order, with the project file looked
330    /// for in `cwd` (default: the process's working directory).
331    pub fn load_in(cwd: Option<&Path>, explicit: Option<&Path>) -> Result<Loaded> {
332        let (path, source) = match explicit {
333            // An agent entry may hand the project file over by path; it is
334            // still a project file and gets no more trust for that.
335            Some(p) if p.file_name() == Some(".farhand.toml".as_ref()) => {
336                (p.to_path_buf(), Source::Project)
337            }
338            Some(p) => (p.to_path_buf(), Source::Explicit),
339            None => Self::locate(cwd)?,
340        };
341        let text = std::fs::read_to_string(&path)
342            .map_err(|e| Error::Config(format!("cannot read {}: {e}", path.display())))?;
343        // Relative `allowed_dirs` entries are resolved against the file's own
344        // directory, but only for a per-project file: the global config
345        // belongs to no directory, so `"."` would mean nothing there.
346        let base = match source {
347            Source::Global => None,
348            _ => path.parent().map(Path::to_path_buf),
349        };
350        let (config, notices) = if source == Source::Project {
351            let global = Self::global_path();
352            let mut notices = Vec::new();
353            // The global config is the floor a project file is measured
354            // against and the source of the user's own deny lists. One that
355            // exists but does not load stops FarHand: falling back to the
356            // defaults would quietly drop both.
357            let baseline = if global.symlink_metadata().is_ok() {
358                let c = std::fs::read_to_string(&global)
359                    .map_err(Error::from)
360                    .and_then(|t| Self::parse(&t))
361                    .map_err(|e| {
362                        Error::Config(format!(
363                            "the global config {} does not load ({e}); fix or remove it",
364                            global.display()
365                        ))
366                    })?;
367                Some(c)
368            } else {
369                None
370            };
371            let (config, more) = Self::parse_project(&text, base.as_deref(), baseline.as_ref())?;
372            notices.extend(more);
373            (config, notices)
374        } else {
375            (Self::parse_at(&text, base.as_deref())?, Vec::new())
376        };
377        Ok(Loaded {
378            config,
379            path,
380            source,
381            notices,
382        })
383    }
384
385    /// Parse a project file living in `base`, measured against `baseline`
386    /// (the global config, if there is one): settings that would loosen it
387    /// are dropped, and each drop is described in the returned notices.
388    pub fn parse_project(
389        text: &str,
390        base: Option<&Path>,
391        baseline: Option<&Config>,
392    ) -> Result<(Self, Vec<String>)> {
393        let mut cfg: Config = toml::from_str(text).map_err(|e| Error::Config(e.to_string()))?;
394        let mut notices = Vec::new();
395        let requested = std::mem::take(&mut cfg.local.allowed_dirs);
396        for d in requested {
397            if d.is_absolute() || d.to_string_lossy().starts_with('~') {
398                notices.push(format!(
399                    "local.allowed_dirs entry `{}` ignored: a project .farhand.toml may only \
400                     allow folders inside its own directory (`\".\"`, `\"assets\"`); put other \
401                     folders in the global config",
402                    d.display()
403                ));
404            } else {
405                cfg.local.allowed_dirs.push(d);
406            }
407        }
408        if let Some(d) = cfg.audit.log_dir.take() {
409            notices.push(format!(
410                "audit.log_dir `{}` ignored: only the global config may move the audit log",
411                d.display()
412            ));
413        }
414        cfg.validate(base)?;
415        // A relative entry is only inside the project as written. A cloned
416        // repository can carry `assets -> ~/Documents` next to its
417        // `.farhand.toml`, so each entry must still be inside the project
418        // folder once symlinks are resolved, and it is kept in that resolved
419        // form so a link swapped in later changes nothing.
420        let project = base
421            .map(|b| {
422                if b.as_os_str().is_empty() {
423                    Path::new(".")
424                } else {
425                    b
426                }
427            })
428            .and_then(|b| b.canonicalize().ok());
429        for d in std::mem::take(&mut cfg.local.allowed_dirs) {
430            match (d.canonicalize(), &project) {
431                (Ok(c), Some(p)) if c.starts_with(p) && c.is_dir() => {
432                    cfg.local.allowed_dirs.push(c)
433                }
434                (Ok(_), _) => notices.push(format!(
435                    "local.allowed_dirs entry `{}` ignored: it is not a folder inside the \
436                     project directory once symlinks are resolved",
437                    d.display()
438                )),
439                (Err(e), _) => notices.push(format!(
440                    "local.allowed_dirs entry `{}` ignored: {e}",
441                    d.display()
442                )),
443            }
444        }
445        cfg.audit.log_dir = baseline.and_then(|b| b.audit.log_dir.clone());
446
447        let floor = baseline.map(|b| b.approval.clone()).unwrap_or_default();
448        let floor = floor.effective();
449        let mut loosened = Vec::new();
450        let mut tools = BTreeMap::new();
451        for (tool, mode) in cfg.approval.effective() {
452            let mode = if mode == ApprovalMode::Auto && floor[tool] == ApprovalMode::Ask {
453                loosened.push(tool);
454                ApprovalMode::Ask
455            } else {
456                mode
457            };
458            tools.insert(tool.to_string(), mode);
459        }
460        if !loosened.is_empty() {
461            notices.push(format!(
462                "[approval] `auto` ignored for {}: a project .farhand.toml may make approval \
463                 stricter, not looser; set it in the global config",
464                loosened.join(", ")
465            ));
466            // Every tool spelled out, so `effective()` no longer depends
467            // on the mode.
468            cfg.approval.tools = tools;
469        }
470
471        // The global deny lists stay in force; a project can only add.
472        if let Some(b) = baseline {
473            for g in &b.guard.deny_globs {
474                if !cfg.guard.deny_globs.contains(g) {
475                    cfg.guard.deny_globs.push(g.clone());
476                }
477            }
478            for r in &b.guard.deny_content {
479                if !cfg.guard.deny_content.contains(r) {
480                    cfg.guard.deny_content.push(r.clone());
481                }
482            }
483        }
484        Ok((cfg, notices))
485    }
486
487    /// Parse a config that belongs to no directory: `allowed_dirs` entries
488    /// must be absolute or `~/...`.
489    pub fn parse(text: &str) -> Result<Self> {
490        Self::parse_at(text, None)
491    }
492
493    /// Parse a config file that lives in `base`; relative `allowed_dirs`
494    /// entries (`"."`, `"assets"`) are resolved against it.
495    pub fn parse_at(text: &str, base: Option<&Path>) -> Result<Self> {
496        let mut cfg: Config = toml::from_str(text).map_err(|e| Error::Config(e.to_string()))?;
497        cfg.validate(base)?;
498        Ok(cfg)
499    }
500
501    fn locate(cwd: Option<&Path>) -> Result<(PathBuf, Source)> {
502        if let Some(p) = std::env::var_os("FARHAND_CONFIG") {
503            return Ok((PathBuf::from(p), Source::Env));
504        }
505        let local = match cwd {
506            Some(d) => d.join(".farhand.toml"),
507            None => PathBuf::from(".farhand.toml"),
508        };
509        if local.is_file() {
510            return Ok((local, Source::Project));
511        }
512        let p = Self::global_path();
513        if p.is_file() {
514            return Ok((p, Source::Global));
515        }
516        Err(Error::NoConfig)
517    }
518
519    pub fn global_path() -> PathBuf {
520        xdg_dir("XDG_CONFIG_HOME", ".config")
521            .join("farhand")
522            .join("config.toml")
523    }
524
525    fn validate(&mut self, base: Option<&Path>) -> Result<()> {
526        if self.remote.host.trim().is_empty() {
527            return Err(Error::Config("remote.host is empty".into()));
528        }
529        if self.remote.host.starts_with('-') {
530            return Err(Error::Config("remote.host may not start with '-'".into()));
531        }
532        let raw = self.remote.workdir.trim();
533        let w = raw.trim_end_matches(['/', '\\']);
534        let w = if w.is_empty() && raw.starts_with('/') {
535            "/".to_string()
536        } else if is_drive_root(raw) {
537            // `C:\` keeps its separator; `C:` alone would be drive-relative.
538            format!("{}\\", &raw[..2])
539        } else {
540            w.to_string()
541        };
542        let windows_abs = w.len() >= 3
543            && w.as_bytes()[0].is_ascii_alphabetic()
544            && w.as_bytes()[1] == b':'
545            && (w.as_bytes()[2] == b'\\' || w.as_bytes()[2] == b'/');
546        if !(w.starts_with('/')
547            || w == "~"
548            || w.starts_with("~/")
549            || w.starts_with("~\\")
550            || windows_abs)
551        {
552            return Err(Error::Config(
553                "remote.workdir must be an absolute path (`/srv/app`, `C:\\work`) or start with `~` \
554                 (the remote home)"
555                    .into(),
556            ));
557        }
558        self.remote.workdir = w;
559        if self.remote.shell.is_empty() {
560            return Err(Error::Config("remote.shell is empty".into()));
561        }
562        if self.limits.command_timeout_secs == 0 || self.limits.max_command_timeout_secs == 0 {
563            return Err(Error::Config("timeouts must be positive".into()));
564        }
565        let mut dirs = Vec::with_capacity(self.local.allowed_dirs.len());
566        let home = dirs::home_dir();
567        let home_canon = home.as_deref().and_then(|h| h.canonicalize().ok());
568        for d in &self.local.allowed_dirs {
569            let mut expanded = expand_home(d);
570            // `..` would let an entry reach above what it names: above the
571            // project folder for a relative entry, anywhere for `~/../x`.
572            if expanded
573                .components()
574                .any(|c| matches!(c, std::path::Component::ParentDir))
575            {
576                return Err(Error::Config(format!(
577                    "local.allowed_dirs entry `{}` may not contain `..`",
578                    d.display()
579                )));
580            }
581            if !expanded.is_absolute() {
582                // A relative entry names the project folder itself or
583                // something inside it.
584                let Some(base) = base else {
585                    return Err(Error::Config(format!(
586                        "local.allowed_dirs entry `{}` is relative; only a project \
587                         .farhand.toml may use relative entries (`\".\"` for its own folder)",
588                        d.display()
589                    )));
590                };
591                expanded = base.join(expanded);
592            }
593            // The allowlist is for a folder the user set aside, never the
594            // home directory or anything above it: that would hand every
595            // non-credential file to the model.
596            let canon = expanded.canonicalize().ok();
597            let covers_home = |h: &Path| {
598                h.starts_with(&expanded) || canon.as_deref().is_some_and(|c| h.starts_with(c))
599            };
600            if expanded.parent().is_none()
601                || home.as_deref().is_some_and(covers_home)
602                || home_canon.as_deref().is_some_and(covers_home)
603            {
604                return Err(Error::Config(format!(
605                    "local.allowed_dirs may not contain `/`, the home directory or a folder \
606                     above it ({}); allow a specific folder instead",
607                    d.display()
608                )));
609            }
610            dirs.push(expanded);
611        }
612        self.local.allowed_dirs = dirs;
613        if let Some(d) = &self.audit.log_dir {
614            self.audit.log_dir = Some(expand_home(d));
615        }
616        for (t, m) in &self.approval.tools {
617            if t == "remote_bash" {
618                return Err(Error::Config(
619                    "approval.tools: `remote_bash` was renamed to `remote_shell` in 0.2.1".into(),
620                ));
621            }
622            if !MUTATING_TOOLS.contains(&t.as_str()) && !READ_ONLY_TOOLS.contains(&t.as_str()) {
623                return Err(Error::Config(format!(
624                    "approval.tools: `{t}` is not a tool (one of {}, {})",
625                    MUTATING_TOOLS.join(", "),
626                    READ_ONLY_TOOLS.join(", ")
627                )));
628            }
629            if *m == ApprovalMode::Strict {
630                return Err(Error::Config(format!(
631                    "approval.tools.{t}: use `ask` or `auto` (`strict` is a global mode)"
632                )));
633            }
634        }
635        Ok(())
636    }
637
638    pub fn audit_dir(&self) -> PathBuf {
639        self.audit.log_dir.clone().unwrap_or_else(|| {
640            xdg_dir("XDG_DATA_HOME", ".local/share")
641                .join("farhand")
642                .join("audit")
643        })
644    }
645}
646
647/// `$VAR` if set, else `~/<fallback>`. The XDG layout is used on every
648/// platform so the documented paths (`~/.config/farhand`,
649/// `~/.local/share/farhand`) are the real ones on macOS too.
650fn xdg_dir(var: &str, fallback: &str) -> PathBuf {
651    if let Some(v) = std::env::var_os(var) {
652        if !v.is_empty() {
653            return PathBuf::from(v);
654        }
655    }
656    dirs::home_dir()
657        .unwrap_or_else(|| PathBuf::from("."))
658        .join(fallback)
659}
660
661/// `~` and `~/x` expand to the home directory; anything else is returned as-is.
662/// `C:\`, `C:/` or `C:` — a Windows drive root, with or without separator.
663fn is_drive_root(s: &str) -> bool {
664    let b = s.as_bytes();
665    (b.len() == 2 || b.len() == 3)
666        && b[0].is_ascii_alphabetic()
667        && b[1] == b':'
668        && (b.len() == 2 || b[2] == b'\\' || b[2] == b'/')
669}
670
671pub fn expand_home(p: &Path) -> PathBuf {
672    let s = p.to_string_lossy();
673    if s == "~" {
674        return dirs::home_dir().unwrap_or_else(|| p.to_path_buf());
675    }
676    if let Some(rest) = s.strip_prefix("~/") {
677        if let Some(home) = dirs::home_dir() {
678            return home.join(rest);
679        }
680    }
681    p.to_path_buf()
682}
683
684const EXAMPLE: &str = r#"# FarHand configuration.
685# As .farhand.toml in a project root, it makes that project remote.
686# As ~/.config/farhand/config.toml it is optional: only needed for
687# activation = "always" (or as the target of --config / FARHAND_CONFIG).
688
689# "project" (default): only directories that carry a .farhand.toml are
690#            remote; anywhere else the agent keeps its local tools and FarHand
691#            stays out of the way.
692# "always":  every agent session on this machine is remote, using the
693#            [remote] section below.
694# Only meaningful in this global file; a project's .farhand.toml is always
695# active in its own directory.
696# activation = "project"
697
698[remote]
699# An alias from ~/.ssh/config or user@host. Keys, ports, jump hosts and
700# agent forwarding all come from your OpenSSH config.
701host = "devbox"
702# Directory on the remote where every command starts and relative paths
703# resolve. Absolute, or `~/...` for the remote user's home. Created on
704# first connection if it does not exist.
705workdir = "~/project"
706# os = "auto"              # "posix" or "windows"; detected on connect, but set it so
707#                          # the model knows which shell it is writing for
708# shell = ["bash", "-lc"]  # Windows: ["powershell"] (default) or ["pwsh"]
709# connect_timeout_secs = 20
710# server_alive_interval_secs = 30
711
712[local]
713# The only local directories the model may list, read from, upload from or
714# download into. Leave empty to close the local filesystem completely.
715# In a project .farhand.toml, "." means this folder: drop files here to
716# upload them, and downloads land here. A project file may only name this
717# folder or folders inside it; absolute and `~` entries belong in the
718# global config. Never allow your home directory or anything above it.
719allowed_dirs = ["."]
720
721[limits]
722# command_timeout_secs = 120
723# max_command_timeout_secs = 1800
724# max_output_bytes = 102400
725# max_read_bytes = 524288
726# max_transfer_bytes = 1073741824
727# max_transfer_files = 5000
728
729[audit]
730# Global config only; a project file cannot move the log.
731# log_dir = "~/.local/share/farhand/audit"
732
733[guard]
734# Extra globs that may never leave this machine (added to the built-in list).
735# deny_globs = ["*.internal"]
736# Extra regexes that mark content as secret.
737# deny_content = ["ACME-[0-9]{12}"]
738
739[approval]
740# "ask":    the agent prompts you before remote_shell, remote_write,
741#           remote_edit, remote_spawn, remote_kill, upload and download;
742#           read-only tools run freely.
743# "auto":   nothing prompts; the audit log is your record.
744# "strict": every tool prompts, reads and searches included.
745# A project .farhand.toml can make this stricter than the global config,
746# never looser: `auto` there only holds where the global config allows it.
747mode = "ask"
748# Per-tool overrides (`ask` or `auto`), any tool name:
749# tools = { remote_write = "auto", remote_edit = "auto", remote_read = "ask" }
750"#;
751
752#[cfg(test)]
753mod tests {
754    use super::*;
755
756    #[test]
757    fn example_parses() {
758        // `farhand init` writes the example as a project file; its `"."`
759        // is that file's folder, and is meaningless in the global config.
760        let cfg = Config::parse_at(Config::example(), Some(Path::new("/proj"))).unwrap();
761        assert_eq!(cfg.remote.host, "devbox");
762        assert_eq!(cfg.remote.shell, vec!["bash", "-lc"]);
763        assert_eq!(cfg.local.allowed_dirs, vec![PathBuf::from("/proj")]);
764        assert!(Config::parse(Config::example()).is_err());
765    }
766
767    #[test]
768    fn rejects_home_and_root_as_allowed_dirs() {
769        for d in ["/", "~"] {
770            let err = Config::parse(&format!(
771                "[remote]\nhost='h'\nworkdir='/w'\n[local]\nallowed_dirs=['{d}']"
772            ))
773            .unwrap_err();
774            assert!(err.to_string().contains("home directory"), "{d}: {err}");
775        }
776        assert!(
777            Config::parse("[remote]\nhost='h'\nworkdir='/w'\n[local]\nallowed_dirs=['~/out']")
778                .is_ok()
779        );
780    }
781
782    #[test]
783    fn relative_allowed_dirs_resolve_against_the_project_file() {
784        let text = "[remote]\nhost='h'\nworkdir='/w'\n[local]\nallowed_dirs=['.', 'assets']";
785        let err = Config::parse(text).unwrap_err();
786        assert!(err.to_string().contains("relative"), "{err}");
787        let cfg = Config::parse_at(text, Some(Path::new("/proj"))).unwrap();
788        assert_eq!(
789            cfg.local.allowed_dirs,
790            vec![PathBuf::from("/proj"), PathBuf::from("/proj/assets")]
791        );
792        let err = Config::parse_at(
793            "[remote]\nhost='h'\nworkdir='/w'\n[local]\nallowed_dirs=['../x']",
794            Some(Path::new("/proj")),
795        )
796        .unwrap_err();
797        assert!(err.to_string().contains(".."), "{err}");
798    }
799
800    #[test]
801    fn old_tool_name_points_at_the_new_one() {
802        let err = Config::parse(
803            "[remote]\nhost='h'\nworkdir='/'\n[approval]\ntools={ remote_bash='ask' }",
804        )
805        .unwrap_err();
806        assert!(
807            err.to_string().contains("renamed to `remote_shell`"),
808            "{err}"
809        );
810    }
811
812    #[test]
813    fn rejects_relative_workdir() {
814        let err = Config::parse("[remote]\nhost='h'\nworkdir='rel'").unwrap_err();
815        assert!(err.to_string().contains("absolute"));
816        let cfg = Config::parse("[remote]\nhost='h'\nworkdir='~/'").unwrap();
817        assert_eq!(cfg.remote.workdir, "~");
818        let cfg = Config::parse("[remote]\nhost='h'\nworkdir='/srv/app/'").unwrap();
819        assert_eq!(cfg.remote.workdir, "/srv/app");
820        let cfg = Config::parse("[remote]\nhost='h'\nworkdir='C:\\work\\'").unwrap();
821        assert_eq!(cfg.remote.workdir, "C:\\work");
822        let cfg = Config::parse("[remote]\nhost='h'\nworkdir='D:/'").unwrap();
823        assert_eq!(cfg.remote.workdir, "D:\\");
824        let cfg = Config::parse("[remote]\nhost='h'\nworkdir='C:'").unwrap();
825        assert_eq!(cfg.remote.workdir, "C:\\");
826        assert!(Config::parse("[remote]\nhost='h'\nworkdir='work'").is_err());
827        let cfg = Config::parse("[remote]\nhost='h'\nworkdir='/'\nos='windows'").unwrap();
828        assert_eq!(cfg.remote.os, Os::Windows);
829    }
830
831    #[test]
832    fn approval_overrides_apply() {
833        let cfg = Config::parse(
834            "[remote]\nhost='h'\nworkdir='/'\n[approval]\nmode='auto'\ntools={ remote_shell='ask' }",
835        )
836        .unwrap();
837        let e = cfg.approval.effective();
838        assert_eq!(e["remote_shell"], ApprovalMode::Ask);
839        assert_eq!(e["remote_write"], ApprovalMode::Auto);
840        assert_eq!(e["remote_read"], ApprovalMode::Auto);
841        assert_eq!(e.len(), MUTATING_TOOLS.len() + READ_ONLY_TOOLS.len());
842
843        let strict = Config::parse(
844            "[remote]\nhost='h'\nworkdir='/'\n[approval]\nmode='strict'\ntools={ remote_info='auto' }",
845        )
846        .unwrap();
847        let e = strict.approval.effective();
848        assert_eq!(e["remote_read"], ApprovalMode::Ask);
849        assert_eq!(e["remote_shell"], ApprovalMode::Ask);
850        assert_eq!(e["remote_info"], ApprovalMode::Auto);
851
852        let default = Config::parse("[remote]\nhost='h'\nworkdir='/'").unwrap();
853        assert_eq!(
854            default.approval.effective()["remote_read"],
855            ApprovalMode::Auto
856        );
857        assert!(Config::parse(
858            "[remote]\nhost='h'\nworkdir='/'\n[approval]\ntools={ nope='auto' }"
859        )
860        .is_err());
861        assert!(Config::parse(
862            "[remote]\nhost='h'\nworkdir='/'\n[approval]\ntools={ upload='strict' }"
863        )
864        .is_err());
865    }
866
867    #[test]
868    fn activation_depends_on_source() {
869        let mk = |activation: &str, source: Source| Loaded {
870            config: Config::parse(&format!(
871                "activation='{activation}'\n[remote]\nhost='h'\nworkdir='/'"
872            ))
873            .unwrap(),
874            path: PathBuf::new(),
875            source,
876            notices: Vec::new(),
877        };
878        assert!(mk("always", Source::Global).active());
879        assert!(!mk("project", Source::Global).active());
880        assert!(mk("project", Source::Project).active());
881        assert!(mk("project", Source::Explicit).active());
882        assert_eq!(
883            Config::parse("[remote]\nhost='h'\nworkdir='/'")
884                .unwrap()
885                .activation,
886            ActivationMode::Project
887        );
888    }
889
890    #[test]
891    fn allowed_dirs_refuse_home_ancestors_and_dotdot() {
892        let home = dirs::home_dir().unwrap();
893        let parent = home.parent().unwrap().display().to_string();
894        for d in [parent.as_str(), "~/..", "~/../x", "/tmp/../etc"] {
895            let err = Config::parse(&format!(
896                "[remote]\nhost='h'\nworkdir='/w'\n[local]\nallowed_dirs=['{d}']"
897            ))
898            .unwrap_err();
899            assert!(err.to_string().contains("allowed_dirs"), "{d}: {err}");
900        }
901    }
902
903    /// Parse `text` as the `.farhand.toml` of a fresh project folder.
904    fn project(text: &str, baseline: Option<&str>) -> (Config, Vec<String>, tempfile::TempDir) {
905        let d = tempfile::tempdir().unwrap();
906        let baseline = baseline.map(|b| Config::parse(b).unwrap());
907        let (cfg, notes) = Config::parse_project(text, Some(d.path()), baseline.as_ref()).unwrap();
908        (cfg, notes, d)
909    }
910
911    #[test]
912    fn project_file_cannot_loosen_approval() {
913        let (cfg, notes, _d) = project(
914            "[remote]\nhost='h'\nworkdir='/'\n[approval]\nmode='auto'",
915            None,
916        );
917        let e = cfg.approval.effective();
918        assert_eq!(e["remote_shell"], ApprovalMode::Ask);
919        assert_eq!(e["upload"], ApprovalMode::Ask);
920        assert_eq!(e["remote_read"], ApprovalMode::Auto);
921        assert_eq!(notes.len(), 1);
922        assert!(notes[0].contains("remote_shell"), "{notes:?}");
923
924        // The global config sets the floor: what it allows, a project may use.
925        let global = "[remote]\nhost='g'\nworkdir='/'\n[approval]\nmode='auto'";
926        let (cfg, notes, _d) = project(
927            "[remote]\nhost='h'\nworkdir='/'\n[approval]\nmode='auto'\ntools={ upload='ask' }",
928            Some(global),
929        );
930        assert!(notes.is_empty(), "{notes:?}");
931        assert_eq!(cfg.approval.effective()["remote_shell"], ApprovalMode::Auto);
932        assert_eq!(cfg.approval.effective()["upload"], ApprovalMode::Ask);
933
934        // Tightening is always fine; loosening strict reads is not.
935        let strict = "[remote]\nhost='g'\nworkdir='/'\n[approval]\nmode='strict'";
936        let (cfg, notes, _d) = project("[remote]\nhost='h'\nworkdir='/'", Some(strict));
937        assert_eq!(cfg.approval.effective()["remote_read"], ApprovalMode::Ask);
938        assert!(notes[0].contains("remote_read"), "{notes:?}");
939    }
940
941    #[test]
942    fn project_file_keeps_to_its_own_folder_and_the_global_log() {
943        let (cfg, notes, d) = project(
944            "[remote]\nhost='h'\nworkdir='/'\n[local]\nallowed_dirs=['.', '/Users', '~/x']\n\
945             [audit]\nlog_dir='/dev'",
946            Some("[remote]\nhost='g'\nworkdir='/'\n[audit]\nlog_dir='/var/log/fh'\n[guard]\ndeny_globs=['*.corp']"),
947        );
948        assert_eq!(
949            cfg.local.allowed_dirs,
950            vec![d.path().canonicalize().unwrap()]
951        );
952        assert_eq!(cfg.audit.log_dir, Some(PathBuf::from("/var/log/fh")));
953        assert_eq!(cfg.guard.deny_globs, vec!["*.corp".to_string()]);
954        assert_eq!(notes.len(), 3, "{notes:?}");
955        let err = Config::parse_project(
956            "[remote]\nhost='h'\nworkdir='/'\n[local]\nallowed_dirs=['../x']",
957            Some(Path::new("/proj")),
958            None,
959        )
960        .unwrap_err();
961        assert!(err.to_string().contains(".."), "{err}");
962    }
963
964    #[cfg(unix)]
965    #[test]
966    fn project_folders_may_not_leave_the_project_through_a_symlink() {
967        let d = tempfile::tempdir().unwrap();
968        let proj = d.path().join("proj");
969        let outside = d.path().join("outside");
970        std::fs::create_dir_all(proj.join("real/sub")).unwrap();
971        std::fs::create_dir_all(&outside).unwrap();
972        std::os::unix::fs::symlink(&outside, proj.join("assets")).unwrap();
973        std::os::unix::fs::symlink(proj.join("real/sub"), proj.join("inner")).unwrap();
974        let (cfg, notes) = Config::parse_project(
975            "[remote]\nhost='h'\nworkdir='/'\n[local]\nallowed_dirs=['assets', 'inner', 'missing', 'real']",
976            Some(&proj),
977            None,
978        )
979        .unwrap();
980        let real = proj.join("real").canonicalize().unwrap();
981        assert_eq!(cfg.local.allowed_dirs, vec![real.join("sub"), real]);
982        assert_eq!(notes.len(), 2, "{notes:?}");
983        assert!(notes[0].contains("/assets`"), "{notes:?}");
984        assert!(notes[1].contains("/missing`"), "{notes:?}");
985    }
986
987    #[test]
988    fn a_farhand_toml_passed_by_path_is_still_a_project_file() {
989        let d = tempfile::tempdir().unwrap();
990        let p = d.path().join(".farhand.toml");
991        std::fs::write(
992            &p,
993            "[remote]\nhost='h'\nworkdir='/'\n[audit]\nlog_dir='/dev'",
994        )
995        .unwrap();
996        let l = Config::load_in(None, Some(&p)).unwrap();
997        assert_eq!(l.source, Source::Project);
998        assert!(
999            l.notices.iter().any(|n| n.contains("log_dir")),
1000            "{:?}",
1001            l.notices
1002        );
1003        let other = d.path().join("fh.toml");
1004        std::fs::copy(&p, &other).unwrap();
1005        let l = Config::load_in(None, Some(&other)).unwrap();
1006        assert_eq!(l.source, Source::Explicit);
1007        assert_eq!(l.config.audit.log_dir, Some(PathBuf::from("/dev")));
1008    }
1009
1010    #[test]
1011    fn rejects_unknown_fields() {
1012        assert!(Config::parse("[remote]\nhost='h'\nworkdir='/'\nbogus=1").is_err());
1013    }
1014}