1use 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 #[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 pub host: String,
49 pub workdir: String,
53 #[serde(default)]
57 pub os: Os,
58 #[serde(default = "default_shell")]
64 pub shell: Vec<String>,
65 #[serde(default = "default_connect_timeout")]
66 pub connect_timeout_secs: u64,
67 #[serde(default = "default_alive_interval")]
69 pub server_alive_interval_secs: u64,
70}
71
72pub fn default_shell() -> Vec<String> {
73 vec!["bash".into(), "-lc".into()]
74}
75
76#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "lowercase")]
79pub enum Os {
80 #[default]
81 Auto,
82 Posix,
84 Windows,
86}
87fn default_connect_timeout() -> u64 {
88 20
89}
90fn default_alive_interval() -> u64 {
91 30
92}
93
94#[derive(Debug, Clone, Default, Serialize, Deserialize)]
95#[serde(deny_unknown_fields)]
96pub struct LocalConfig {
97 #[serde(default)]
100 pub allowed_dirs: Vec<PathBuf>,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104#[serde(deny_unknown_fields)]
105pub struct Limits {
106 #[serde(default = "default_command_timeout")]
107 pub command_timeout_secs: u64,
108 #[serde(default = "default_max_command_timeout")]
109 pub max_command_timeout_secs: u64,
110 #[serde(default = "default_max_output_bytes")]
111 pub max_output_bytes: usize,
112 #[serde(default = "default_max_read_bytes")]
113 pub max_read_bytes: usize,
114 #[serde(default = "default_max_transfer_bytes")]
115 pub max_transfer_bytes: u64,
116 #[serde(default = "default_max_transfer_files")]
117 pub max_transfer_files: usize,
118}
119
120fn default_command_timeout() -> u64 {
121 120
122}
123fn default_max_command_timeout() -> u64 {
124 1800
125}
126fn default_max_output_bytes() -> usize {
127 100 * 1024
128}
129fn default_max_read_bytes() -> usize {
130 512 * 1024
131}
132fn default_max_transfer_bytes() -> u64 {
133 1024 * 1024 * 1024
134}
135fn default_max_transfer_files() -> usize {
136 5000
137}
138
139impl Default for Limits {
140 fn default() -> Self {
141 Self {
142 command_timeout_secs: default_command_timeout(),
143 max_command_timeout_secs: default_max_command_timeout(),
144 max_output_bytes: default_max_output_bytes(),
145 max_read_bytes: default_max_read_bytes(),
146 max_transfer_bytes: default_max_transfer_bytes(),
147 max_transfer_files: default_max_transfer_files(),
148 }
149 }
150}
151
152#[derive(Debug, Clone, Default, Serialize, Deserialize)]
153#[serde(deny_unknown_fields)]
154pub struct AuditConfig {
155 pub log_dir: Option<PathBuf>,
159}
160
161#[derive(Debug, Clone, Default, Serialize, Deserialize)]
162#[serde(deny_unknown_fields)]
163pub struct GuardConfig {
164 #[serde(default)]
167 pub deny_globs: Vec<String>,
168 #[serde(default)]
170 pub deny_content: Vec<String>,
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
176#[serde(rename_all = "lowercase")]
177pub enum ActivationMode {
178 Always,
180 #[default]
185 Project,
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum Source {
191 Explicit,
192 Env,
193 Project,
194 Global,
195}
196
197#[derive(Debug, Clone)]
200pub struct Loaded {
201 pub config: Config,
202 pub path: PathBuf,
203 pub source: Source,
204}
205
206impl Loaded {
207 pub fn active(&self) -> bool {
208 match self.source {
209 Source::Explicit | Source::Env | Source::Project => true,
210 Source::Global => self.config.activation == ActivationMode::Always,
211 }
212 }
213}
214
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(rename_all = "lowercase")]
220pub enum ApprovalMode {
221 Ask,
224 Auto,
226 Strict,
228}
229
230impl ApprovalMode {
231 pub fn as_str(self) -> &'static str {
232 match self {
233 ApprovalMode::Ask => "ask",
234 ApprovalMode::Auto => "auto",
235 ApprovalMode::Strict => "strict",
236 }
237 }
238}
239
240pub const MUTATING_TOOLS: &[&str] = &[
242 "remote_shell",
243 "remote_write",
244 "remote_edit",
245 "upload",
246 "download",
247];
248
249pub const READ_ONLY_TOOLS: &[&str] = &[
251 "remote_read",
252 "remote_ls",
253 "remote_glob",
254 "remote_grep",
255 "local_ls",
256 "local_read",
257 "remote_info",
258];
259
260#[derive(Debug, Clone, Serialize, Deserialize)]
261#[serde(deny_unknown_fields)]
262pub struct ApprovalConfig {
263 #[serde(default = "default_approval")]
265 pub mode: ApprovalMode,
266 #[serde(default)]
268 pub tools: BTreeMap<String, ApprovalMode>,
269}
270
271fn default_approval() -> ApprovalMode {
272 ApprovalMode::Ask
273}
274
275impl Default for ApprovalConfig {
276 fn default() -> Self {
277 Self {
278 mode: default_approval(),
279 tools: BTreeMap::new(),
280 }
281 }
282}
283
284impl ApprovalConfig {
285 pub fn effective(&self) -> BTreeMap<&'static str, ApprovalMode> {
288 let mutating = match self.mode {
289 ApprovalMode::Auto => ApprovalMode::Auto,
290 ApprovalMode::Ask | ApprovalMode::Strict => ApprovalMode::Ask,
291 };
292 let read_only = match self.mode {
293 ApprovalMode::Strict => ApprovalMode::Ask,
294 ApprovalMode::Ask | ApprovalMode::Auto => ApprovalMode::Auto,
295 };
296 MUTATING_TOOLS
297 .iter()
298 .map(|t| (*t, mutating))
299 .chain(READ_ONLY_TOOLS.iter().map(|t| (*t, read_only)))
300 .map(|(t, m)| (t, self.tools.get(t).copied().unwrap_or(m)))
301 .collect()
302 }
303}
304
305impl Config {
306 pub fn example() -> &'static str {
307 EXAMPLE
308 }
309
310 pub fn load(explicit: Option<&Path>) -> Result<(Self, PathBuf)> {
313 let l = Self::load_in(None, explicit)?;
314 Ok((l.config, l.path))
315 }
316
317 pub fn load_in(cwd: Option<&Path>, explicit: Option<&Path>) -> Result<Loaded> {
320 let (path, source) = match explicit {
321 Some(p) => (p.to_path_buf(), Source::Explicit),
322 None => Self::locate(cwd)?,
323 };
324 let text = std::fs::read_to_string(&path)
325 .map_err(|e| Error::Config(format!("cannot read {}: {e}", path.display())))?;
326 let base = match source {
330 Source::Global => None,
331 _ => path.parent().map(Path::to_path_buf),
332 };
333 let config = Self::parse_at(&text, base.as_deref())?;
334 Ok(Loaded {
335 config,
336 path,
337 source,
338 })
339 }
340
341 pub fn parse(text: &str) -> Result<Self> {
344 Self::parse_at(text, None)
345 }
346
347 pub fn parse_at(text: &str, base: Option<&Path>) -> Result<Self> {
350 let mut cfg: Config = toml::from_str(text).map_err(|e| Error::Config(e.to_string()))?;
351 cfg.validate(base)?;
352 Ok(cfg)
353 }
354
355 fn locate(cwd: Option<&Path>) -> Result<(PathBuf, Source)> {
356 if let Some(p) = std::env::var_os("FARHAND_CONFIG") {
357 return Ok((PathBuf::from(p), Source::Env));
358 }
359 let local = match cwd {
360 Some(d) => d.join(".farhand.toml"),
361 None => PathBuf::from(".farhand.toml"),
362 };
363 if local.is_file() {
364 return Ok((local, Source::Project));
365 }
366 let p = Self::global_path();
367 if p.is_file() {
368 return Ok((p, Source::Global));
369 }
370 Err(Error::NoConfig)
371 }
372
373 pub fn global_path() -> PathBuf {
374 xdg_dir("XDG_CONFIG_HOME", ".config")
375 .join("farhand")
376 .join("config.toml")
377 }
378
379 fn validate(&mut self, base: Option<&Path>) -> Result<()> {
380 if self.remote.host.trim().is_empty() {
381 return Err(Error::Config("remote.host is empty".into()));
382 }
383 if self.remote.host.starts_with('-') {
384 return Err(Error::Config("remote.host may not start with '-'".into()));
385 }
386 let raw = self.remote.workdir.trim();
387 let w = raw.trim_end_matches(['/', '\\']);
388 let w = if w.is_empty() && raw.starts_with('/') {
389 "/".to_string()
390 } else if is_drive_root(raw) {
391 format!("{}\\", &raw[..2])
393 } else {
394 w.to_string()
395 };
396 let windows_abs = w.len() >= 3
397 && w.as_bytes()[0].is_ascii_alphabetic()
398 && w.as_bytes()[1] == b':'
399 && (w.as_bytes()[2] == b'\\' || w.as_bytes()[2] == b'/');
400 if !(w.starts_with('/')
401 || w == "~"
402 || w.starts_with("~/")
403 || w.starts_with("~\\")
404 || windows_abs)
405 {
406 return Err(Error::Config(
407 "remote.workdir must be an absolute path (`/srv/app`, `C:\\work`) or start with `~` \
408 (the remote home)"
409 .into(),
410 ));
411 }
412 self.remote.workdir = w;
413 if self.remote.shell.is_empty() {
414 return Err(Error::Config("remote.shell is empty".into()));
415 }
416 if self.limits.command_timeout_secs == 0 || self.limits.max_command_timeout_secs == 0 {
417 return Err(Error::Config("timeouts must be positive".into()));
418 }
419 let mut dirs = Vec::with_capacity(self.local.allowed_dirs.len());
420 let home = dirs::home_dir();
421 for d in &self.local.allowed_dirs {
422 let mut expanded = expand_home(d);
423 if !expanded.is_absolute() {
424 let Some(base) = base else {
428 return Err(Error::Config(format!(
429 "local.allowed_dirs entry `{}` is relative; only a project \
430 .farhand.toml may use relative entries (`\".\"` for its own folder)",
431 d.display()
432 )));
433 };
434 if expanded
435 .components()
436 .any(|c| matches!(c, std::path::Component::ParentDir))
437 {
438 return Err(Error::Config(format!(
439 "local.allowed_dirs entry `{}` may not contain `..`",
440 d.display()
441 )));
442 }
443 expanded = base.join(expanded);
444 }
445 if expanded.parent().is_none() || home.as_deref() == Some(expanded.as_path()) {
450 return Err(Error::Config(format!(
451 "local.allowed_dirs may not contain `/` or the home directory ({}); \
452 allow a specific folder instead",
453 d.display()
454 )));
455 }
456 dirs.push(expanded);
457 }
458 self.local.allowed_dirs = dirs;
459 if let Some(d) = &self.audit.log_dir {
460 self.audit.log_dir = Some(expand_home(d));
461 }
462 for (t, m) in &self.approval.tools {
463 if t == "remote_bash" {
464 return Err(Error::Config(
465 "approval.tools: `remote_bash` was renamed to `remote_shell` in 0.2.1".into(),
466 ));
467 }
468 if !MUTATING_TOOLS.contains(&t.as_str()) && !READ_ONLY_TOOLS.contains(&t.as_str()) {
469 return Err(Error::Config(format!(
470 "approval.tools: `{t}` is not a tool (one of {}, {})",
471 MUTATING_TOOLS.join(", "),
472 READ_ONLY_TOOLS.join(", ")
473 )));
474 }
475 if *m == ApprovalMode::Strict {
476 return Err(Error::Config(format!(
477 "approval.tools.{t}: use `ask` or `auto` (`strict` is a global mode)"
478 )));
479 }
480 }
481 Ok(())
482 }
483
484 pub fn audit_dir(&self) -> PathBuf {
485 self.audit.log_dir.clone().unwrap_or_else(|| {
486 xdg_dir("XDG_DATA_HOME", ".local/share")
487 .join("farhand")
488 .join("audit")
489 })
490 }
491}
492
493fn xdg_dir(var: &str, fallback: &str) -> PathBuf {
497 if let Some(v) = std::env::var_os(var) {
498 if !v.is_empty() {
499 return PathBuf::from(v);
500 }
501 }
502 dirs::home_dir()
503 .unwrap_or_else(|| PathBuf::from("."))
504 .join(fallback)
505}
506
507fn is_drive_root(s: &str) -> bool {
510 let b = s.as_bytes();
511 (b.len() == 2 || b.len() == 3)
512 && b[0].is_ascii_alphabetic()
513 && b[1] == b':'
514 && (b.len() == 2 || b[2] == b'\\' || b[2] == b'/')
515}
516
517pub fn expand_home(p: &Path) -> PathBuf {
518 let s = p.to_string_lossy();
519 if s == "~" {
520 return dirs::home_dir().unwrap_or_else(|| p.to_path_buf());
521 }
522 if let Some(rest) = s.strip_prefix("~/") {
523 if let Some(home) = dirs::home_dir() {
524 return home.join(rest);
525 }
526 }
527 p.to_path_buf()
528}
529
530const EXAMPLE: &str = r#"# FarHand configuration.
531# As .farhand.toml in a project root, it makes that project remote.
532# As ~/.config/farhand/config.toml it is optional: only needed for
533# activation = "always" (or as the target of --config / FARHAND_CONFIG).
534
535# "project" (default): only directories that carry a .farhand.toml are
536# remote; anywhere else the agent keeps its local tools and FarHand
537# stays out of the way.
538# "always": every agent session on this machine is remote, using the
539# [remote] section below.
540# Only meaningful in this global file; a project's .farhand.toml is always
541# active in its own directory.
542# activation = "project"
543
544[remote]
545# An alias from ~/.ssh/config or user@host. Keys, ports, jump hosts and
546# agent forwarding all come from your OpenSSH config.
547host = "devbox"
548# Directory on the remote where every command starts and relative paths
549# resolve. Absolute, or `~/...` for the remote user's home. Created on
550# first connection if it does not exist.
551workdir = "~/project"
552# os = "auto" # "posix" or "windows"; detected on connect, but set it so
553# # the model knows which shell it is writing for
554# shell = ["bash", "-lc"] # Windows: ["powershell"] (default) or ["pwsh"]
555# connect_timeout_secs = 20
556# server_alive_interval_secs = 30
557
558[local]
559# The only local directories the model may list, read from, upload from or
560# download into. Leave empty to close the local filesystem completely.
561# In a project .farhand.toml, "." means this folder: drop files here to
562# upload them, and downloads land here. Never allow your home directory.
563allowed_dirs = ["."]
564
565[limits]
566# command_timeout_secs = 120
567# max_command_timeout_secs = 1800
568# max_output_bytes = 102400
569# max_read_bytes = 524288
570# max_transfer_bytes = 1073741824
571# max_transfer_files = 5000
572
573[audit]
574# log_dir = "~/.local/share/farhand/audit"
575
576[guard]
577# Extra globs that may never leave this machine (added to the built-in list).
578# deny_globs = ["*.internal"]
579# Extra regexes that mark content as secret.
580# deny_content = ["ACME-[0-9]{12}"]
581
582[approval]
583# "ask": the agent prompts you before remote_shell, remote_write,
584# remote_edit, upload and download; read-only tools run freely.
585# "auto": nothing prompts; the audit log is your record.
586# "strict": every tool prompts, reads and searches included.
587mode = "ask"
588# Per-tool overrides (`ask` or `auto`), any tool name:
589# tools = { remote_write = "auto", remote_edit = "auto", remote_read = "ask" }
590"#;
591
592#[cfg(test)]
593mod tests {
594 use super::*;
595
596 #[test]
597 fn example_parses() {
598 let cfg = Config::parse_at(Config::example(), Some(Path::new("/proj"))).unwrap();
601 assert_eq!(cfg.remote.host, "devbox");
602 assert_eq!(cfg.remote.shell, vec!["bash", "-lc"]);
603 assert_eq!(cfg.local.allowed_dirs, vec![PathBuf::from("/proj")]);
604 assert!(Config::parse(Config::example()).is_err());
605 }
606
607 #[test]
608 fn rejects_home_and_root_as_allowed_dirs() {
609 for d in ["/", "~"] {
610 let err = Config::parse(&format!(
611 "[remote]\nhost='h'\nworkdir='/w'\n[local]\nallowed_dirs=['{d}']"
612 ))
613 .unwrap_err();
614 assert!(err.to_string().contains("home directory"), "{d}: {err}");
615 }
616 assert!(
617 Config::parse("[remote]\nhost='h'\nworkdir='/w'\n[local]\nallowed_dirs=['~/out']")
618 .is_ok()
619 );
620 }
621
622 #[test]
623 fn relative_allowed_dirs_resolve_against_the_project_file() {
624 let text = "[remote]\nhost='h'\nworkdir='/w'\n[local]\nallowed_dirs=['.', 'assets']";
625 let err = Config::parse(text).unwrap_err();
626 assert!(err.to_string().contains("relative"), "{err}");
627 let cfg = Config::parse_at(text, Some(Path::new("/proj"))).unwrap();
628 assert_eq!(
629 cfg.local.allowed_dirs,
630 vec![PathBuf::from("/proj"), PathBuf::from("/proj/assets")]
631 );
632 let err = Config::parse_at(
633 "[remote]\nhost='h'\nworkdir='/w'\n[local]\nallowed_dirs=['../x']",
634 Some(Path::new("/proj")),
635 )
636 .unwrap_err();
637 assert!(err.to_string().contains(".."), "{err}");
638 }
639
640 #[test]
641 fn old_tool_name_points_at_the_new_one() {
642 let err = Config::parse(
643 "[remote]\nhost='h'\nworkdir='/'\n[approval]\ntools={ remote_bash='ask' }",
644 )
645 .unwrap_err();
646 assert!(
647 err.to_string().contains("renamed to `remote_shell`"),
648 "{err}"
649 );
650 }
651
652 #[test]
653 fn rejects_relative_workdir() {
654 let err = Config::parse("[remote]\nhost='h'\nworkdir='rel'").unwrap_err();
655 assert!(err.to_string().contains("absolute"));
656 let cfg = Config::parse("[remote]\nhost='h'\nworkdir='~/'").unwrap();
657 assert_eq!(cfg.remote.workdir, "~");
658 let cfg = Config::parse("[remote]\nhost='h'\nworkdir='/srv/app/'").unwrap();
659 assert_eq!(cfg.remote.workdir, "/srv/app");
660 let cfg = Config::parse("[remote]\nhost='h'\nworkdir='C:\\work\\'").unwrap();
661 assert_eq!(cfg.remote.workdir, "C:\\work");
662 let cfg = Config::parse("[remote]\nhost='h'\nworkdir='D:/'").unwrap();
663 assert_eq!(cfg.remote.workdir, "D:\\");
664 let cfg = Config::parse("[remote]\nhost='h'\nworkdir='C:'").unwrap();
665 assert_eq!(cfg.remote.workdir, "C:\\");
666 assert!(Config::parse("[remote]\nhost='h'\nworkdir='work'").is_err());
667 let cfg = Config::parse("[remote]\nhost='h'\nworkdir='/'\nos='windows'").unwrap();
668 assert_eq!(cfg.remote.os, Os::Windows);
669 }
670
671 #[test]
672 fn approval_overrides_apply() {
673 let cfg = Config::parse(
674 "[remote]\nhost='h'\nworkdir='/'\n[approval]\nmode='auto'\ntools={ remote_shell='ask' }",
675 )
676 .unwrap();
677 let e = cfg.approval.effective();
678 assert_eq!(e["remote_shell"], ApprovalMode::Ask);
679 assert_eq!(e["remote_write"], ApprovalMode::Auto);
680 assert_eq!(e["remote_read"], ApprovalMode::Auto);
681 assert_eq!(e.len(), MUTATING_TOOLS.len() + READ_ONLY_TOOLS.len());
682
683 let strict = Config::parse(
684 "[remote]\nhost='h'\nworkdir='/'\n[approval]\nmode='strict'\ntools={ remote_info='auto' }",
685 )
686 .unwrap();
687 let e = strict.approval.effective();
688 assert_eq!(e["remote_read"], ApprovalMode::Ask);
689 assert_eq!(e["remote_shell"], ApprovalMode::Ask);
690 assert_eq!(e["remote_info"], ApprovalMode::Auto);
691
692 let default = Config::parse("[remote]\nhost='h'\nworkdir='/'").unwrap();
693 assert_eq!(
694 default.approval.effective()["remote_read"],
695 ApprovalMode::Auto
696 );
697 assert!(Config::parse(
698 "[remote]\nhost='h'\nworkdir='/'\n[approval]\ntools={ nope='auto' }"
699 )
700 .is_err());
701 assert!(Config::parse(
702 "[remote]\nhost='h'\nworkdir='/'\n[approval]\ntools={ upload='strict' }"
703 )
704 .is_err());
705 }
706
707 #[test]
708 fn activation_depends_on_source() {
709 let mk = |activation: &str, source: Source| Loaded {
710 config: Config::parse(&format!(
711 "activation='{activation}'\n[remote]\nhost='h'\nworkdir='/'"
712 ))
713 .unwrap(),
714 path: PathBuf::new(),
715 source,
716 };
717 assert!(mk("always", Source::Global).active());
718 assert!(!mk("project", Source::Global).active());
719 assert!(mk("project", Source::Project).active());
720 assert!(mk("project", Source::Explicit).active());
721 assert_eq!(
722 Config::parse("[remote]\nhost='h'\nworkdir='/'")
723 .unwrap()
724 .activation,
725 ActivationMode::Project
726 );
727 }
728
729 #[test]
730 fn rejects_unknown_fields() {
731 assert!(Config::parse("[remote]\nhost='h'\nworkdir='/'\nbogus=1").is_err());
732 }
733}