Skip to main content

farhand_core/
guard.rs

1//! The secret guard. Nothing leaves this machine for the remote without
2//! passing through here: command text, file contents written or edited,
3//! and every file an upload would send.
4//!
5//! Two checks:
6//! - **path**: the local file's name or any directory on its path marks it
7//!   as credential material (`.env`, `*.pem`, `~/.ssh/...`, agent configs
8//!   that embed API keys, ...);
9//! - **content**: the bytes contain something shaped like a credential
10//!   (private-key blocks, well-known token prefixes, a `key = "..."`
11//!   assignment with a high-entropy value).
12//!
13//! The guard errs toward refusing. A false positive costs one retry with a
14//! clear message; a false negative puts a credential on a machine the user
15//! chose not to trust with it.
16
17use std::path::{Component, Path};
18
19use globset::{Glob, GlobBuilder, GlobSet, GlobSetBuilder};
20use regex::bytes::{Regex, RegexSet, RegexSetBuilder};
21
22use crate::config::GuardConfig;
23use crate::error::{Error, Result};
24
25/// File names and globs that are credential material by convention.
26/// Matched against the basename.
27const DENIED_NAMES: &[&str] = &[
28    ".env",
29    ".env.*",
30    "*.env",
31    ".envrc",
32    "*.pem",
33    "*.key",
34    "*.p12",
35    "*.pfx",
36    "*.jks",
37    "*.keystore",
38    "*.ppk",
39    "*.ovpn",
40    "*.kdbx",
41    "*.gpg",
42    "*.asc",
43    "*.secret",
44    "*.secrets",
45    "secrets.*",
46    "secret.*",
47    "id_rsa*",
48    "id_dsa*",
49    "id_ecdsa*",
50    "id_ed25519*",
51    "authorized_keys",
52    ".netrc",
53    "_netrc",
54    ".npmrc",
55    ".pypirc",
56    ".yarnrc",
57    ".git-credentials",
58    ".htpasswd",
59    ".vault-token",
60    ".boto",
61    ".s3cfg",
62    ".pgpass",
63    ".my.cnf",
64    "credentials",
65    "credentials.*",
66    "*credentials*.json",
67    "*service-account*.json",
68    "*service_account*.json",
69    "client_secret*.json",
70    "*.tfstate",
71    "*.tfstate.*",
72    "*.tfvars",
73    "terraform.rc",
74    ".terraformrc",
75    "kubeconfig",
76    "opencode.json",
77    "opencode.jsonc",
78    ".farhand.toml",
79    ".mcp.json",
80    "mcp.json",
81    "claude_desktop_config.json",
82    ".claude.json",
83    "auth.json",
84    "hosts.yml",
85    "wallet.dat",
86];
87
88/// Directory components that mark everything beneath them as off-limits.
89const DENIED_DIRS: &[&str] = &[
90    ".ssh",
91    ".aws",
92    ".azure",
93    ".gcloud",
94    ".gnupg",
95    ".kube",
96    ".docker",
97    ".config",
98    ".claude",
99    ".opencode",
100    ".codex",
101    ".cursor",
102    ".gemini",
103    ".vscode-server",
104    ".password-store",
105    ".secrets",
106    "secrets",
107    ".1password",
108    "Keychains",
109    ".Trash",
110];
111
112/// Content shapes that are credentials. Each entry is `(rule name, regex)`;
113/// the name is what the audit log and the refusal message carry, never the
114/// matched text.
115const DENIED_CONTENT: &[(&str, &str)] = &[
116    (
117        "private-key-block",
118        r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY( BLOCK)?-----",
119    ),
120    (
121        "openai-or-anthropic-key",
122        r"\bsk-(ant-|proj-|svcacct-)?[A-Za-z0-9_-]{20,}",
123    ),
124    (
125        "github-token",
126        r"\b(gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})",
127    ),
128    ("aws-access-key", r"\b(AKIA|ASIA)[0-9A-Z]{16}\b"),
129    ("google-api-key", r"\bAIza[0-9A-Za-z_-]{35}\b"),
130    ("slack-token", r"\bxox[abprs]-[0-9A-Za-z-]{10,}"),
131    ("stripe-key", r"\b[sr]k_(live|test)_[0-9A-Za-z]{20,}"),
132    (
133        "jwt",
134        r"\beyJ[A-Za-z0-9_-]{15,}\.[A-Za-z0-9_-]{15,}\.[A-Za-z0-9_-]{10,}",
135    ),
136    ("huggingface-token", r"\bhf_[A-Za-z0-9]{30,}"),
137    ("npm-token", r"\bnpm_[A-Za-z0-9]{36}\b"),
138    ("gitlab-token", r"\bglpat-[A-Za-z0-9_-]{20,}"),
139    ("openrouter-key", r"\bsk-or-v1-[0-9a-f]{40,}"),
140    (
141        "bearer-header",
142        r"(?i)\bauthorization\s*[:=]\s*['\x22]?\s*(bearer|basic|token)\s+[A-Za-z0-9._+/=-]{20,}",
143    ),
144    (
145        "url-with-password",
146        r"(?i)\b[a-z][a-z0-9+.-]*://[^\s/:@]+:[^\s/@]{6,}@[^\s/]+",
147    ),
148    (
149        "credential-assignment",
150        r"(?i)\b(api[_-]?key|secret[_-]?key|access[_-]?key|private[_-]?key|auth[_-]?token|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|passwd|token|secret)['\x22]?\s*[:=]\s*['\x22]?[A-Za-z0-9_./+=-]{24,}",
151    ),
152];
153
154/// Names that may never be *created* locally by a download, on top of
155/// [`DENIED_NAMES`] and [`DENIED_DIRS`]: files and directories an editor,
156/// shell or agent executes or trusts when it opens the folder. A remote that
157/// plants one of these inside the allowlist would otherwise get code run
158/// locally, or repoint FarHand itself. Ordinary project files (package.json,
159/// Makefile) are not listed: nothing runs them until the user does.
160const LOCAL_TRUSTED_NAMES: &[&str] = &[
161    ".farhand.toml",
162    ".vscode",
163    ".idea",
164    ".git",
165    ".husky",
166    ".devcontainer",
167    ".envrc",
168    ".direnv",
169    ".mise.toml",
170    "mise.toml",
171    ".pre-commit-config.yaml",
172    "CLAUDE.md",
173    "AGENTS.md",
174    ".cursorrules",
175    ".windsurfrules",
176];
177
178#[derive(Debug, Clone)]
179pub struct Finding {
180    pub rule: &'static str,
181    pub detail: String,
182}
183
184impl Finding {
185    fn deny(&self, what: &str) -> Error {
186        Error::Denied(format!(
187            "{what} looks like credential material (rule: {}, {}) and will not be sent to the \
188             remote. If this is not a secret, rename it or rewrite the value; FarHand never \
189             uploads credentials.",
190            self.rule, self.detail
191        ))
192    }
193}
194
195/// How much of a file `ensure_upload_file` looks at per step.
196const SCAN_CHUNK: usize = 1024 * 1024;
197/// Bytes carried over between steps. Every `DENIED_CONTENT` match, and any
198/// credential a `deny_content` pattern is meant to catch, is far shorter.
199const SCAN_OVERLAP: usize = 64 * 1024;
200
201fn any_case(pattern: &str) -> std::result::Result<Glob, globset::Error> {
202    GlobBuilder::new(pattern).case_insensitive(true).build()
203}
204
205/// Fill `buf` from `r` as far as the data goes; short only at EOF.
206fn read_up_to(r: &mut impl std::io::Read, buf: &mut [u8]) -> std::io::Result<usize> {
207    let mut filled = 0;
208    while filled < buf.len() {
209        match r.read(&mut buf[filled..]) {
210            Ok(0) => break,
211            Ok(n) => filled += n,
212            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
213            Err(e) => return Err(e),
214        }
215    }
216    Ok(filled)
217}
218
219pub struct Guard {
220    names: GlobSet,
221    extra_paths: GlobSet,
222    content_names: Vec<&'static str>,
223    content: RegexSet,
224    extra_content: Vec<Regex>,
225}
226
227impl Guard {
228    pub fn new(cfg: &GuardConfig) -> Result<Self> {
229        // Case-insensitive: on the default macOS and Windows volumes
230        // `OPENCODE.JSONC` is the same file as `opencode.jsonc`.
231        let mut names = GlobSetBuilder::new();
232        for n in DENIED_NAMES {
233            names.add(any_case(n).map_err(|e| Error::Config(e.to_string()))?);
234        }
235        let mut extra = GlobSetBuilder::new();
236        for g in &cfg.deny_globs {
237            extra.add(any_case(g).map_err(|e| Error::Config(format!("guard.deny_globs: {e}")))?);
238        }
239        // ASCII semantics for `\b` and `\s`: every credential shape above is
240        // ASCII, and a Unicode word boundary makes the regex engine abandon
241        // its DFA on each non-ASCII byte, which turns scanning a binary
242        // file into a crawl (4 MB/s against 600 MB/s in practice).
243        let content = RegexSetBuilder::new(DENIED_CONTENT.iter().map(|(_, r)| *r))
244            .unicode(false)
245            .build()
246            .map_err(|e| Error::Config(e.to_string()))?;
247        let extra_content = cfg
248            .deny_content
249            .iter()
250            .map(|r| Regex::new(r).map_err(|e| Error::Config(format!("guard.deny_content: {e}"))))
251            .collect::<Result<Vec<_>>>()?;
252        Ok(Self {
253            names: names.build().map_err(|e| Error::Config(e.to_string()))?,
254            extra_paths: extra.build().map_err(|e| Error::Config(e.to_string()))?,
255            content_names: DENIED_CONTENT.iter().map(|(n, _)| *n).collect(),
256            content,
257            extra_content,
258        })
259    }
260
261    /// Why a local path may not be uploaded, if it may not.
262    pub fn check_path(&self, path: &Path) -> Option<Finding> {
263        for comp in path.components() {
264            if let Component::Normal(c) = comp {
265                let s = c.to_string_lossy();
266                if DENIED_DIRS.iter().any(|d| d.eq_ignore_ascii_case(&s)) {
267                    return Some(Finding {
268                        rule: "denied-directory",
269                        detail: format!("path contains `{s}/`"),
270                    });
271                }
272            }
273        }
274        let name = path
275            .file_name()
276            .map(|n| n.to_string_lossy())
277            .unwrap_or_default();
278        if self.names.is_match(name.as_ref()) {
279            return Some(Finding {
280                rule: "denied-filename",
281                detail: format!("file name `{name}`"),
282            });
283        }
284        if self.extra_paths.is_match(path) || self.extra_paths.is_match(name.as_ref()) {
285            return Some(Finding {
286                rule: "config-deny-glob",
287                detail: format!("`{}` matches guard.deny_globs", path.display()),
288            });
289        }
290        None
291    }
292
293    /// Why a download may not land at this local path (given relative to
294    /// the allowlisted root), if it may not. Credential-shaped names are
295    /// refused as on upload, plus anything an editor or agent would trust
296    /// or execute when opening the folder.
297    pub fn check_download_target(&self, rel: &Path) -> Option<Finding> {
298        if let Some(f) = self.check_path(rel) {
299            return Some(f);
300        }
301        for comp in rel.components() {
302            if let Component::Normal(c) = comp {
303                let s = c.to_string_lossy();
304                if LOCAL_TRUSTED_NAMES
305                    .iter()
306                    .any(|d| d.eq_ignore_ascii_case(&s))
307                {
308                    return Some(Finding {
309                        rule: "local-trusted-name",
310                        detail: format!(
311                            "`{s}` is something an editor or agent would execute or trust locally"
312                        ),
313                    });
314                }
315            }
316        }
317        None
318    }
319
320    /// Why these bytes may not be sent, if they may not.
321    pub fn check_content(&self, bytes: &[u8]) -> Option<Finding> {
322        let hits = self.content.matches(bytes);
323        if let Some(i) = hits.iter().next() {
324            return Some(Finding {
325                rule: self.content_names[i],
326                detail: "content matched".into(),
327            });
328        }
329        for (i, re) in self.extra_content.iter().enumerate() {
330            if re.is_match(bytes) {
331                return Some(Finding {
332                    rule: "config-deny-content",
333                    detail: format!("guard.deny_content[{i}]"),
334                });
335            }
336        }
337        None
338    }
339
340    /// Refuse if `bytes` (described as `what` in the message) carry a secret.
341    pub fn ensure_content(&self, what: &str, bytes: &[u8]) -> Result<()> {
342        match self.check_content(bytes) {
343            Some(f) => Err(f.deny(what)),
344            None => Ok(()),
345        }
346    }
347
348    /// Refuse if `path` or its `bytes` may not be uploaded.
349    pub fn ensure_upload(&self, path: &Path, bytes: &[u8]) -> Result<()> {
350        if let Some(f) = self.check_path(path) {
351            return Err(f.deny(&format!("`{}`", path.display())));
352        }
353        self.ensure_content(&format!("the content of `{}`", path.display()), bytes)
354    }
355
356    /// `ensure_upload` for a file of any size: the content is scanned in
357    /// windows that overlap by `SCAN_OVERLAP`, so a secret sitting on a
358    /// window boundary is still seen and memory stays flat.
359    pub fn ensure_upload_file(&self, path: &Path) -> Result<()> {
360        self.ensure_upload_reader(path, &mut std::fs::File::open(path)?)
361    }
362
363    /// `ensure_upload_file` for a file already open as `file`.
364    pub fn ensure_upload_reader(&self, path: &Path, file: &mut impl std::io::Read) -> Result<()> {
365        if let Some(f) = self.check_path(path) {
366            return Err(f.deny(&format!("`{}`", path.display())));
367        }
368        let mut window: Vec<u8> = Vec::with_capacity(SCAN_CHUNK + SCAN_OVERLAP);
369        loop {
370            let keep = window.len().saturating_sub(SCAN_OVERLAP);
371            window.drain(..keep);
372            let start = window.len();
373            window.resize(start + SCAN_CHUNK, 0);
374            let n = read_up_to(file, &mut window[start..])?;
375            window.truncate(start + n);
376            if n == 0 {
377                return Ok(());
378            }
379            self.ensure_content(&format!("the content of `{}`", path.display()), &window)?;
380        }
381    }
382
383    /// A scanner for content as it is sent: see [`StreamGuard`].
384    pub fn stream(&self, path: &Path) -> StreamGuard<'_> {
385        StreamGuard {
386            guard: self,
387            what: format!("the content of `{}`", path.display()),
388            held: Vec::new(),
389        }
390    }
391
392    /// Refuse if a download may not be written to `rel` (relative to the
393    /// allowlisted root it lands in).
394    pub fn ensure_download(&self, rel: &Path) -> Result<()> {
395        match self.check_download_target(rel) {
396            Some(f) => Err(Error::Denied(format!(
397                "download refused: `{}` (rule: {}, {}). Downloads never create credential \
398                 files or anything a local tool would execute; pick another name or fetch it \
399                 yourself.",
400                rel.display(),
401                f.rule,
402                f.detail
403            ))),
404            None => Ok(()),
405        }
406    }
407}
408
409/// Checks an upload in the form it actually leaves in, chunk by chunk, so
410/// a file that changes after its first scan cannot send what that scan
411/// never saw. The last `SCAN_OVERLAP` bytes are held back until the next
412/// chunk has been scanned with them, so a secret spanning two chunks is
413/// seen whole before any of it is released — the same windows
414/// `ensure_upload_file` uses.
415pub struct StreamGuard<'g> {
416    guard: &'g Guard,
417    what: String,
418    held: Vec<u8>,
419}
420
421impl StreamGuard<'_> {
422    /// Add `data`; returns the bytes that may now be sent.
423    pub fn feed(&mut self, data: &[u8]) -> Result<Vec<u8>> {
424        self.held.extend_from_slice(data);
425        self.guard.ensure_content(&self.what, &self.held)?;
426        let release = self.held.len().saturating_sub(SCAN_OVERLAP);
427        Ok(self.held.drain(..release).collect())
428    }
429
430    /// The bytes still held back at the end of the data; they were
431    /// scanned by the last `feed`.
432    pub fn finish(self) -> Vec<u8> {
433        self.held
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use std::path::PathBuf;
441
442    fn guard() -> Guard {
443        Guard::new(&GuardConfig::default()).unwrap()
444    }
445
446    #[test]
447    fn stream_releases_nothing_a_later_chunk_could_complete() {
448        let g = guard();
449        let path = Path::new("data.bin");
450        let mut first = vec![b'x'; SCAN_CHUNK];
451        first.extend_from_slice(b" AKIAABCDEFGH");
452        let mut s = g.stream(path);
453        let sent = s.feed(&first).unwrap();
454        assert_eq!(sent.len(), first.len() - SCAN_OVERLAP);
455        assert!(!sent.windows(4).any(|w| w == b"AKIA"));
456        let err = s.feed(b"IJKLMNOP and more").unwrap_err().to_string();
457        assert!(err.contains("aws-access-key"), "{err}");
458
459        // Clean data comes out whole, in order.
460        let mut s = g.stream(path);
461        let mut out = s.feed(&first[..100]).unwrap();
462        out.extend(s.feed(&vec![b'y'; SCAN_CHUNK]).unwrap());
463        out.extend(s.finish());
464        assert_eq!(out.len(), 100 + SCAN_CHUNK);
465        assert_eq!(&out[..100], &first[..100]);
466    }
467
468    #[test]
469    fn scans_large_files_across_window_boundaries() {
470        let g = guard();
471        let dir = std::env::temp_dir().join(format!("farhand-guard-{}", std::process::id()));
472        std::fs::create_dir_all(&dir).unwrap();
473        let clean = dir.join("clean.bin");
474        std::fs::write(&clean, vec![b'x'; SCAN_CHUNK * 2 + 17]).unwrap();
475        assert!(g.ensure_upload_file(&clean).is_ok());
476        // A key that straddles the first window boundary.
477        let mut bytes = vec![b'x'; SCAN_CHUNK - 8];
478        bytes.extend_from_slice(b" AKIAABCDEFGHIJKLMNOP ");
479        bytes.extend(std::iter::repeat_n(b'x', SCAN_CHUNK));
480        let dirty = dir.join("dirty.bin");
481        std::fs::write(&dirty, &bytes).unwrap();
482        let err = g.ensure_upload_file(&dirty).unwrap_err().to_string();
483        assert!(err.contains("aws-access-key"), "{err}");
484        std::fs::remove_dir_all(&dir).unwrap();
485    }
486
487    #[test]
488    fn names_match_in_any_case() {
489        let g = Guard::new(&GuardConfig {
490            deny_globs: vec!["*.corp".into()],
491            ..Default::default()
492        })
493        .unwrap();
494        // None of these exist: the check is on the name a download would
495        // create or an upload would send.
496        for name in [
497            "OPENCODE.JSONC",
498            "OpenCode.json",
499            ".ENV",
500            ".Env.Local",
501            "ID_RSA",
502            "Server.PEM",
503            ".FARHAND.TOML",
504            ".MCP.json",
505            "Notes.CORP",
506        ] {
507            let rel = PathBuf::from("dl").join(name);
508            assert!(g.check_path(&rel).is_some(), "{name}");
509            assert!(g.check_download_target(&rel).is_some(), "{name}");
510        }
511        assert!(g.check_path(Path::new("dl/README.md")).is_none());
512    }
513
514    #[test]
515    fn download_targets() {
516        let g = guard();
517        for p in [
518            ".claude/settings.json",
519            "proj/.vscode/tasks.json",
520            ".farhand.toml",
521            "x/.git/hooks/pre-commit",
522            "id_rsa",
523            "sub/CLAUDE.md",
524        ] {
525            assert!(g.check_download_target(Path::new(p)).is_some(), "{p}");
526        }
527        for p in ["logs/app.log", "package.json", "src/main.rs", "Makefile"] {
528            assert!(g.check_download_target(Path::new(p)).is_none(), "{p}");
529        }
530    }
531
532    #[test]
533    fn denies_well_known_files() {
534        let g = guard();
535        for p in [
536            "/x/.env",
537            "/x/.env.local",
538            "/x/prod.env",
539            "/x/server.pem",
540            "/x/id_rsa",
541            "/x/id_ed25519.pub",
542            "/home/me/.ssh/config",
543            "/home/me/.config/opencode/opencode.jsonc",
544            "/x/.mcp.json",
545            "/x/credentials.json",
546            "/x/gcp-service-account-1234.json",
547            "/x/terraform.tfstate",
548            "/x/.aws/credentials",
549        ] {
550            assert!(
551                g.check_path(&PathBuf::from(p)).is_some(),
552                "{p} should be denied"
553            );
554        }
555    }
556
557    #[test]
558    fn allows_ordinary_files() {
559        let g = guard();
560        for p in [
561            "/x/main.rs",
562            "/x/README.md",
563            "/x/env.example.md",
564            "/x/src/keyboard.rs",
565        ] {
566            assert!(
567                g.check_path(&PathBuf::from(p)).is_none(),
568                "{p} should be allowed"
569            );
570        }
571    }
572
573    #[test]
574    fn denies_secret_shapes() {
575        let g = guard();
576        let cases: &[(&str, &str)] = &[
577            (
578                "-----BEGIN OPENSSH PRIVATE KEY-----\nabc",
579                "private-key-block",
580            ),
581            (
582                "key: sk-ant-api03-abcdefghijklmnopqrstuvwxyz0123",
583                "openai-or-anthropic-key",
584            ),
585            ("ghp_abcdefghijklmnopqrstuvwxyz0123456789", "github-token"),
586            ("github_pat_11ABCDEFG0123456789abcdefghij", "github-token"),
587            ("AKIAIOSFODNN7EXAMPLE", "aws-access-key"),
588            ("xoxb-1234567890-abcdefghij", "slack-token"),
589            (
590                "Authorization: Bearer abcdefghijklmnopqrstuvwxyz",
591                "bearer-header",
592            ),
593            (
594                "postgres://user:hunter2hunter2@db.internal/app",
595                "url-with-password",
596            ),
597            (
598                "API_KEY = \"a1b2c3d4e5f6g7h8i9j0k1l2m3n4\"",
599                "credential-assignment",
600            ),
601            (
602                "\"apiKey\": \"a1b2c3d4e5f6g7h8i9j0k1l2m3n4\"",
603                "credential-assignment",
604            ),
605        ];
606        for (text, rule) in cases {
607            let f = g
608                .check_content(text.as_bytes())
609                .unwrap_or_else(|| panic!("{text} not caught"));
610            assert_eq!(f.rule, *rule, "{text}");
611        }
612    }
613
614    #[test]
615    fn allows_ordinary_code() {
616        let g = guard();
617        for text in [
618            "fn main() { println!(\"hello\"); }",
619            "let token = parse_token(input);",
620            "password = \"changeme\"",
621            "const API_KEY_HEADER: &str = \"x-api-key\";",
622            "export OPENAI_API_KEY=$(cat ~/.secrets/openai)",
623            "curl -H 'Authorization: Bearer $TOKEN' https://x",
624            "git clone https://github.com/CogFlux/farhand.git",
625        ] {
626            assert!(
627                g.check_content(text.as_bytes()).is_none(),
628                "{text} wrongly denied"
629            );
630        }
631    }
632
633    #[test]
634    fn extra_rules_apply() {
635        let cfg = GuardConfig {
636            deny_globs: vec!["*.internal".into()],
637            deny_content: vec!["ACME-[0-9]{6}".into()],
638        };
639        let g = Guard::new(&cfg).unwrap();
640        assert_eq!(
641            g.check_path(Path::new("/x/notes.internal")).unwrap().rule,
642            "config-deny-glob"
643        );
644        assert_eq!(
645            g.check_content(b"ref ACME-123456").unwrap().rule,
646            "config-deny-content"
647        );
648    }
649}