Skip to main content

farhand_core/
config.rs

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