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