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        let config = Self::parse(&text)?;
307        Ok(Loaded {
308            config,
309            path,
310            source,
311        })
312    }
313
314    pub fn parse(text: &str) -> Result<Self> {
315        let mut cfg: Config = toml::from_str(text).map_err(|e| Error::Config(e.to_string()))?;
316        cfg.validate()?;
317        Ok(cfg)
318    }
319
320    fn locate(cwd: Option<&Path>) -> Result<(PathBuf, Source)> {
321        if let Some(p) = std::env::var_os("FARHAND_CONFIG") {
322            return Ok((PathBuf::from(p), Source::Env));
323        }
324        let local = match cwd {
325            Some(d) => d.join(".farhand.toml"),
326            None => PathBuf::from(".farhand.toml"),
327        };
328        if local.is_file() {
329            return Ok((local, Source::Project));
330        }
331        let p = Self::global_path();
332        if p.is_file() {
333            return Ok((p, Source::Global));
334        }
335        Err(Error::NoConfig)
336    }
337
338    pub fn global_path() -> PathBuf {
339        xdg_dir("XDG_CONFIG_HOME", ".config")
340            .join("farhand")
341            .join("config.toml")
342    }
343
344    fn validate(&mut self) -> Result<()> {
345        if self.remote.host.trim().is_empty() {
346            return Err(Error::Config("remote.host is empty".into()));
347        }
348        if self.remote.host.starts_with('-') {
349            return Err(Error::Config("remote.host may not start with '-'".into()));
350        }
351        let w = self.remote.workdir.trim_end_matches('/');
352        let w = if w.is_empty() && self.remote.workdir.starts_with('/') {
353            "/".to_string()
354        } else {
355            w.to_string()
356        };
357        if !(w.starts_with('/') || w == "~" || w.starts_with("~/")) {
358            return Err(Error::Config(
359                "remote.workdir must be an absolute path or start with `~` (the remote home)"
360                    .into(),
361            ));
362        }
363        self.remote.workdir = w;
364        if self.remote.shell.is_empty() {
365            return Err(Error::Config("remote.shell is empty".into()));
366        }
367        if self.limits.command_timeout_secs == 0 || self.limits.max_command_timeout_secs == 0 {
368            return Err(Error::Config("timeouts must be positive".into()));
369        }
370        let mut dirs = Vec::with_capacity(self.local.allowed_dirs.len());
371        let home = dirs::home_dir();
372        for d in &self.local.allowed_dirs {
373            let expanded = expand_home(d);
374            if !expanded.is_absolute() {
375                return Err(Error::Config(format!(
376                    "local.allowed_dirs entry must be absolute: {}",
377                    d.display()
378                )));
379            }
380            // The allowlist is for a folder the user set aside, never the
381            // whole machine or home: that would hand every non-credential
382            // file to the model, and a project-level config could do it
383            // without the user noticing.
384            if expanded.parent().is_none() || home.as_deref() == Some(expanded.as_path()) {
385                return Err(Error::Config(format!(
386                    "local.allowed_dirs may not contain `/` or the home directory ({}); \
387                     allow a specific folder instead",
388                    d.display()
389                )));
390            }
391            dirs.push(expanded);
392        }
393        self.local.allowed_dirs = dirs;
394        if let Some(d) = &self.audit.log_dir {
395            self.audit.log_dir = Some(expand_home(d));
396        }
397        for (t, m) in &self.approval.tools {
398            if !MUTATING_TOOLS.contains(&t.as_str()) && !READ_ONLY_TOOLS.contains(&t.as_str()) {
399                return Err(Error::Config(format!(
400                    "approval.tools: `{t}` is not a tool (one of {}, {})",
401                    MUTATING_TOOLS.join(", "),
402                    READ_ONLY_TOOLS.join(", ")
403                )));
404            }
405            if *m == ApprovalMode::Strict {
406                return Err(Error::Config(format!(
407                    "approval.tools.{t}: use `ask` or `auto` (`strict` is a global mode)"
408                )));
409            }
410        }
411        Ok(())
412    }
413
414    pub fn audit_dir(&self) -> PathBuf {
415        self.audit.log_dir.clone().unwrap_or_else(|| {
416            xdg_dir("XDG_DATA_HOME", ".local/share")
417                .join("farhand")
418                .join("audit")
419        })
420    }
421}
422
423/// `$VAR` if set, else `~/<fallback>`. The XDG layout is used on every
424/// platform so the documented paths (`~/.config/farhand`,
425/// `~/.local/share/farhand`) are the real ones on macOS too.
426fn xdg_dir(var: &str, fallback: &str) -> PathBuf {
427    if let Some(v) = std::env::var_os(var) {
428        if !v.is_empty() {
429            return PathBuf::from(v);
430        }
431    }
432    dirs::home_dir()
433        .unwrap_or_else(|| PathBuf::from("."))
434        .join(fallback)
435}
436
437/// `~` and `~/x` expand to the home directory; anything else is returned as-is.
438pub fn expand_home(p: &Path) -> PathBuf {
439    let s = p.to_string_lossy();
440    if s == "~" {
441        return dirs::home_dir().unwrap_or_else(|| p.to_path_buf());
442    }
443    if let Some(rest) = s.strip_prefix("~/") {
444        if let Some(home) = dirs::home_dir() {
445            return home.join(rest);
446        }
447    }
448    p.to_path_buf()
449}
450
451const EXAMPLE: &str = r#"# FarHand configuration.
452# As .farhand.toml in a project root, it makes that project remote.
453# As ~/.config/farhand/config.toml it is optional: only needed for
454# activation = "always" (or as the target of --config / FARHAND_CONFIG).
455
456# "project" (default): only directories that carry a .farhand.toml are
457#            remote; anywhere else the agent keeps its local tools and FarHand
458#            stays out of the way.
459# "always":  every agent session on this machine is remote, using the
460#            [remote] section below.
461# Only meaningful in this global file; a project's .farhand.toml is always
462# active in its own directory.
463# activation = "project"
464
465[remote]
466# An alias from ~/.ssh/config or user@host. Keys, ports, jump hosts and
467# agent forwarding all come from your OpenSSH config.
468host = "devbox"
469# Directory on the remote where every command starts and relative paths
470# resolve. Absolute, or `~/...` for the remote user's home.
471workdir = "~/project"
472# shell = ["bash", "-lc"]
473# connect_timeout_secs = 20
474# server_alive_interval_secs = 30
475
476[local]
477# The only local directories the model may list, read from, upload from or
478# download into. Leave empty to close the local filesystem completely.
479# Never point this at your home directory or a config directory.
480allowed_dirs = ["~/FarHand-Outbox"]
481
482[limits]
483# command_timeout_secs = 120
484# max_command_timeout_secs = 1800
485# max_output_bytes = 102400
486# max_read_bytes = 524288
487# max_transfer_bytes = 209715200
488# max_transfer_files = 5000
489
490[audit]
491# log_dir = "~/.local/share/farhand/audit"
492
493[guard]
494# Extra globs that may never leave this machine (added to the built-in list).
495# deny_globs = ["*.internal"]
496# Extra regexes that mark content as secret.
497# deny_content = ["ACME-[0-9]{12}"]
498
499[approval]
500# "ask":    the agent prompts you before remote_bash, remote_write,
501#           remote_edit, upload and download; read-only tools run freely.
502# "auto":   nothing prompts; the audit log is your record.
503# "strict": every tool prompts, reads and searches included.
504mode = "ask"
505# Per-tool overrides (`ask` or `auto`), any tool name:
506# tools = { remote_write = "auto", remote_edit = "auto", remote_read = "ask" }
507"#;
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    #[test]
514    fn example_parses() {
515        let cfg = Config::parse(Config::example()).unwrap();
516        assert_eq!(cfg.remote.host, "devbox");
517        assert_eq!(cfg.remote.shell, vec!["bash", "-lc"]);
518        assert_eq!(cfg.local.allowed_dirs.len(), 1);
519        assert!(cfg.local.allowed_dirs[0].is_absolute());
520    }
521
522    #[test]
523    fn rejects_home_and_root_as_allowed_dirs() {
524        for d in ["/", "~"] {
525            let err = Config::parse(&format!(
526                "[remote]\nhost='h'\nworkdir='/w'\n[local]\nallowed_dirs=['{d}']"
527            ))
528            .unwrap_err();
529            assert!(err.to_string().contains("home directory"), "{d}: {err}");
530        }
531        assert!(
532            Config::parse("[remote]\nhost='h'\nworkdir='/w'\n[local]\nallowed_dirs=['~/out']")
533                .is_ok()
534        );
535    }
536
537    #[test]
538    fn rejects_relative_workdir() {
539        let err = Config::parse("[remote]\nhost='h'\nworkdir='rel'").unwrap_err();
540        assert!(err.to_string().contains("absolute"));
541        let cfg = Config::parse("[remote]\nhost='h'\nworkdir='~/'").unwrap();
542        assert_eq!(cfg.remote.workdir, "~");
543        let cfg = Config::parse("[remote]\nhost='h'\nworkdir='/srv/app/'").unwrap();
544        assert_eq!(cfg.remote.workdir, "/srv/app");
545    }
546
547    #[test]
548    fn approval_overrides_apply() {
549        let cfg = Config::parse(
550            "[remote]\nhost='h'\nworkdir='/'\n[approval]\nmode='auto'\ntools={ remote_bash='ask' }",
551        )
552        .unwrap();
553        let e = cfg.approval.effective();
554        assert_eq!(e["remote_bash"], ApprovalMode::Ask);
555        assert_eq!(e["remote_write"], ApprovalMode::Auto);
556        assert_eq!(e["remote_read"], ApprovalMode::Auto);
557        assert_eq!(e.len(), MUTATING_TOOLS.len() + READ_ONLY_TOOLS.len());
558
559        let strict = Config::parse(
560            "[remote]\nhost='h'\nworkdir='/'\n[approval]\nmode='strict'\ntools={ remote_info='auto' }",
561        )
562        .unwrap();
563        let e = strict.approval.effective();
564        assert_eq!(e["remote_read"], ApprovalMode::Ask);
565        assert_eq!(e["remote_bash"], ApprovalMode::Ask);
566        assert_eq!(e["remote_info"], ApprovalMode::Auto);
567
568        let default = Config::parse("[remote]\nhost='h'\nworkdir='/'").unwrap();
569        assert_eq!(
570            default.approval.effective()["remote_read"],
571            ApprovalMode::Auto
572        );
573        assert!(Config::parse(
574            "[remote]\nhost='h'\nworkdir='/'\n[approval]\ntools={ nope='auto' }"
575        )
576        .is_err());
577        assert!(Config::parse(
578            "[remote]\nhost='h'\nworkdir='/'\n[approval]\ntools={ upload='strict' }"
579        )
580        .is_err());
581    }
582
583    #[test]
584    fn activation_depends_on_source() {
585        let mk = |activation: &str, source: Source| Loaded {
586            config: Config::parse(&format!(
587                "activation='{activation}'\n[remote]\nhost='h'\nworkdir='/'"
588            ))
589            .unwrap(),
590            path: PathBuf::new(),
591            source,
592        };
593        assert!(mk("always", Source::Global).active());
594        assert!(!mk("project", Source::Global).active());
595        assert!(mk("project", Source::Project).active());
596        assert!(mk("project", Source::Explicit).active());
597        assert_eq!(
598            Config::parse("[remote]\nhost='h'\nworkdir='/'")
599                .unwrap()
600                .activation,
601            ActivationMode::Project
602        );
603    }
604
605    #[test]
606    fn rejects_unknown_fields() {
607        assert!(Config::parse("[remote]\nhost='h'\nworkdir='/'\nbogus=1").is_err());
608    }
609}