sync from monorepo @ 5408ddc3
This commit is contained in:
@@ -8,7 +8,7 @@ Drop a `.botignore` file in your project root. Fermata reads it and blocks your
|
||||
.env
|
||||
.env.*
|
||||
secrets/**
|
||||
conf/localsettings.yaml
|
||||
conf/settings.local.yaml
|
||||
```
|
||||
|
||||
That's all it takes.
|
||||
@@ -90,8 +90,8 @@ Create a `.botignore` at your project root. Gitignore syntax. Blocks both reads
|
||||
secrets/**
|
||||
|
||||
# Local config overrides
|
||||
conf/localsettings.yaml
|
||||
conf/localtestsettings.yaml
|
||||
conf/settings.local.yaml
|
||||
conf/settings.test.yaml
|
||||
|
||||
# Generated files — let the tools rebuild them, not patch them
|
||||
dist/**
|
||||
@@ -107,7 +107,7 @@ For cases where `.botignore`'s uniform read+write block isn't granular enough:
|
||||
```toml
|
||||
[read]
|
||||
# Block reading secrets outright
|
||||
patterns = [".env*", "secrets/**", "conf/localsettings.yaml"]
|
||||
patterns = [".env*", "secrets/**", "conf/settings.local.yaml"]
|
||||
|
||||
[write]
|
||||
# Allow reading vendor code but block patching it
|
||||
@@ -156,12 +156,12 @@ When Claude attempts a `Read(.env)`, `Write(vendor/foo.js)`, or `Bash(rm ./secre
|
||||
|
||||
## Real-world scenario
|
||||
|
||||
A project has `.env`, `conf/localsettings.yaml`, and a `vendor/` tree it doesn't want patched. With `.botignore`:
|
||||
A project has `.env`, `conf/settings.local.yaml`, and a `vendor/` tree it doesn't want patched. With `.botignore`:
|
||||
|
||||
```gitignore
|
||||
.env
|
||||
.env.*
|
||||
conf/localsettings.yaml
|
||||
conf/settings.local.yaml
|
||||
vendor/**
|
||||
```
|
||||
|
||||
@@ -169,8 +169,8 @@ Claude attempts to read credentials:
|
||||
|
||||
```
|
||||
Tool: Read
|
||||
Path: ./conf/localsettings.yaml
|
||||
Decision: BLOCK — matched rule "conf/localsettings.yaml" (.botignore)
|
||||
Path: ./conf/settings.local.yaml
|
||||
Decision: BLOCK — matched rule "conf/settings.local.yaml" (.botignore)
|
||||
```
|
||||
|
||||
Claude attempts to read application code:
|
||||
@@ -186,7 +186,7 @@ Claude attempts to run `cat .env` via bash — which would bypass a path-only ch
|
||||
```toml
|
||||
# botignore.toml
|
||||
[bash]
|
||||
deny = ["cat .env*", "cat conf/localsettings*"]
|
||||
deny = ["cat .env*", "cat conf/settings.local*"]
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Markers checked in priority order when walking up from a target path.
|
||||
const MARKERS: &[&str] = &["botignore.toml", ".botignore", ".git"];
|
||||
/// Strong markers that definitively identify a project root.
|
||||
const STRONG_MARKERS: &[&str] = &["botignore.toml", ".botignore.toml", ".git"];
|
||||
|
||||
/// Walk upward from `target` (or its parent if `target` is a file) looking
|
||||
/// for the nearest project root. Roots are identified by the presence of
|
||||
/// any marker in `MARKERS`. Walks from the **target file's location**, not
|
||||
/// from cwd, because agents `cd` around.
|
||||
/// for the nearest project root. Strong markers (`botignore.toml`,
|
||||
/// `.botignore.toml`, `.git`) stop the walk immediately. A `.botignore`
|
||||
/// file is remembered as a fallback but does not stop the walk — the search
|
||||
/// continues upward for a stronger boundary. If none is found, the
|
||||
/// `.botignore` location is used.
|
||||
pub fn find_project_root(target: &Path) -> Option<PathBuf> {
|
||||
let start = if target.is_file() {
|
||||
target.parent()?
|
||||
@@ -14,14 +16,18 @@ pub fn find_project_root(target: &Path) -> Option<PathBuf> {
|
||||
target
|
||||
};
|
||||
|
||||
let mut fallback: Option<PathBuf> = None;
|
||||
let mut current = Some(start);
|
||||
while let Some(dir) = current {
|
||||
for marker in MARKERS {
|
||||
for marker in STRONG_MARKERS {
|
||||
if dir.join(marker).exists() {
|
||||
return Some(dir.to_path_buf());
|
||||
}
|
||||
}
|
||||
if fallback.is_none() && dir.join(".botignore").exists() {
|
||||
fallback = Some(dir.to_path_buf());
|
||||
}
|
||||
current = dir.parent();
|
||||
}
|
||||
None
|
||||
fallback
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ fn finds_botignore_toml_first() {
|
||||
let root = tmp.path();
|
||||
fs::create_dir_all(root.join("sub/deep")).unwrap();
|
||||
fs::write(root.join("botignore.toml"), "").unwrap();
|
||||
fs::write(root.join(".botignore"), "").unwrap();
|
||||
fs::write(root.join(".botignore.toml"), "").unwrap();
|
||||
fs::create_dir_all(root.join(".git")).unwrap();
|
||||
|
||||
let target = root.join("sub/deep/file.rs");
|
||||
@@ -19,11 +19,11 @@ fn finds_botignore_toml_first() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_botignore() {
|
||||
fn finds_dot_botignore_toml() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::create_dir_all(root.join("sub")).unwrap();
|
||||
fs::write(root.join(".botignore"), "").unwrap();
|
||||
fs::write(root.join(".botignore.toml"), "").unwrap();
|
||||
|
||||
let target = root.join("sub/file.rs");
|
||||
fs::write(&target, "").unwrap();
|
||||
@@ -32,6 +32,57 @@ fn falls_back_to_botignore() {
|
||||
assert_eq!(found, root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn botignore_alone_does_not_stop_walk() {
|
||||
// A bare .botignore is a policy file, not a project boundary.
|
||||
// The walk should continue past it to find a real root marker.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::create_dir_all(root.join("a/b")).unwrap();
|
||||
fs::create_dir_all(root.join(".git")).unwrap();
|
||||
fs::write(root.join("a/.botignore"), "*.secret").unwrap();
|
||||
|
||||
let target = root.join("a/b/file.rs");
|
||||
fs::write(&target, "").unwrap();
|
||||
|
||||
// Should find root (with .git), NOT root/a (with .botignore)
|
||||
let found = find_project_root(&target).unwrap();
|
||||
assert_eq!(found, root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn botignore_used_as_fallback() {
|
||||
// If only .botignore exists (no strong marker), it serves as a fallback
|
||||
// root so that policy is still enforced.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::create_dir_all(root.join("sub")).unwrap();
|
||||
fs::write(root.join(".botignore"), "*.secret").unwrap();
|
||||
|
||||
let target = root.join("sub/file.rs");
|
||||
fs::write(&target, "").unwrap();
|
||||
|
||||
let found = find_project_root(&target).unwrap();
|
||||
assert_eq!(found, root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strong_marker_preferred_over_botignore_fallback() {
|
||||
// .botignore at a/b/, .git at root — walk past .botignore, use root.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::create_dir_all(root.join("a/b/c")).unwrap();
|
||||
fs::create_dir_all(root.join(".git")).unwrap();
|
||||
fs::write(root.join("a/b/.botignore"), "*.key").unwrap();
|
||||
|
||||
let target = root.join("a/b/c/file.rs");
|
||||
fs::write(&target, "").unwrap();
|
||||
|
||||
// Should find root (with .git), not a/b (with .botignore)
|
||||
let found = find_project_root(&target).unwrap();
|
||||
assert_eq!(found, root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_git() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -59,7 +110,7 @@ fn walks_up_from_file_path_not_cwd() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::create_dir_all(root.join("a/b/c")).unwrap();
|
||||
fs::write(root.join("a/.botignore"), "").unwrap();
|
||||
fs::write(root.join("a/botignore.toml"), "").unwrap();
|
||||
|
||||
let target = root.join("a/b/c/file.rs");
|
||||
fs::write(&target, "").unwrap();
|
||||
|
||||
Reference in New Issue
Block a user