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