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,
52 #[serde(default = "default_shell")]
56 pub shell: Vec<String>,
57 #[serde(default = "default_connect_timeout")]
58 pub connect_timeout_secs: u64,
59 #[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 #[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 pub log_dir: Option<PathBuf>,
139}
140
141#[derive(Debug, Clone, Default, Serialize, Deserialize)]
142#[serde(deny_unknown_fields)]
143pub struct GuardConfig {
144 #[serde(default)]
147 pub deny_globs: Vec<String>,
148 #[serde(default)]
150 pub deny_content: Vec<String>,
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
156#[serde(rename_all = "lowercase")]
157pub enum ActivationMode {
158 Always,
160 #[default]
165 Project,
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum Source {
171 Explicit,
172 Env,
173 Project,
174 Global,
175}
176
177#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
199#[serde(rename_all = "lowercase")]
200pub enum ApprovalMode {
201 Ask,
204 Auto,
206 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
220pub const MUTATING_TOOLS: &[&str] = &[
222 "remote_bash",
223 "remote_write",
224 "remote_edit",
225 "upload",
226 "download",
227];
228
229pub 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 #[serde(default = "default_approval")]
245 pub mode: ApprovalMode,
246 #[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 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 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 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 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 pub fn parse(text: &str) -> Result<Self> {
324 Self::parse_at(text, None)
325 }
326
327 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 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 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
454fn 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
468pub 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 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}