🏗️ fermata: redaction-first security model, unified .botsecrets config
Realign fermata around redaction (PostToolUse) as the primary security layer, with access control (PreToolUse) as supplementary write/bash protection. Remove botignore.toml — policy rules now live in .botsecrets [policy] section. Add fermata.toml as an alias for .botsecrets. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
//! Tests for `.botsecrets [policy]` section integration with `Policy`,
|
||||
//! including `fermata.toml` alias support.
|
||||
|
||||
use dirigent_fermata::core::{Decision, Op, Policy};
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Helper: create a temp project dir with optional `.botignore` and `.botsecrets` files.
|
||||
fn make_project(botignore: &str, botsecrets: &str) -> TempDir {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
if !botignore.is_empty() {
|
||||
fs::write(tmp.path().join(".botignore"), botignore).unwrap();
|
||||
}
|
||||
if !botsecrets.is_empty() {
|
||||
fs::write(tmp.path().join(".botsecrets"), botsecrets).unwrap();
|
||||
}
|
||||
tmp
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn botsecrets_policy_write_works() {
|
||||
let tmp = make_project(
|
||||
"",
|
||||
r#"
|
||||
[policy.write]
|
||||
patterns = ["vendor/**"]
|
||||
"#,
|
||||
);
|
||||
let policy = Policy::load(tmp.path()).unwrap();
|
||||
let target = tmp.path().join("vendor/lib.rs");
|
||||
fs::create_dir_all(target.parent().unwrap()).unwrap();
|
||||
fs::write(&target, "").unwrap();
|
||||
|
||||
// Write to vendor/** should be denied via .botsecrets [policy.write]
|
||||
assert!(matches!(
|
||||
policy.check(Op::Write, &target).unwrap(),
|
||||
Decision::Deny(_)
|
||||
));
|
||||
// Read should still be allowed (no read rules)
|
||||
assert_eq!(policy.check(Op::Read, &target).unwrap(), Decision::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn botsecrets_policy_read_works() {
|
||||
let tmp = make_project(
|
||||
"",
|
||||
r#"
|
||||
[policy.read]
|
||||
patterns = ["secrets/**"]
|
||||
"#,
|
||||
);
|
||||
let policy = Policy::load(tmp.path()).unwrap();
|
||||
let target = tmp.path().join("secrets/key.pem");
|
||||
fs::create_dir_all(target.parent().unwrap()).unwrap();
|
||||
fs::write(&target, "").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
policy.check(Op::Read, &target).unwrap(),
|
||||
Decision::Deny(_)
|
||||
));
|
||||
assert_eq!(policy.check(Op::Write, &target).unwrap(), Decision::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn botsecrets_without_policy_section_does_not_affect_policy() {
|
||||
let tmp = make_project(
|
||||
"",
|
||||
r#"
|
||||
[keys]
|
||||
include = ["MY_CUSTOM_KEY"]
|
||||
"#,
|
||||
);
|
||||
let policy = Policy::load(tmp.path()).unwrap();
|
||||
|
||||
// Everything should be allowed — no policy rules
|
||||
let target = tmp.path().join("vendor/lib.rs");
|
||||
fs::create_dir_all(target.parent().unwrap()).unwrap();
|
||||
fs::write(&target, "").unwrap();
|
||||
assert_eq!(policy.check(Op::Read, &target).unwrap(), Decision::Allow);
|
||||
assert_eq!(policy.check(Op::Write, &target).unwrap(), Decision::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn botsecrets_policy_bash_deny_works() {
|
||||
let tmp = make_project(
|
||||
"",
|
||||
r#"
|
||||
[policy.bash]
|
||||
deny = ["rm -rf /"]
|
||||
ask = ["git push"]
|
||||
"#,
|
||||
);
|
||||
let policy = Policy::load(tmp.path()).unwrap();
|
||||
|
||||
let d = policy.check_command("rm -rf /").unwrap();
|
||||
assert!(matches!(d, Decision::Deny(_)));
|
||||
|
||||
let d = policy.check_command("git push origin main").unwrap();
|
||||
assert!(matches!(d, Decision::Ask(_)));
|
||||
|
||||
let d = policy.check_command("cargo build").unwrap();
|
||||
assert_eq!(d, Decision::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_botsecrets_falls_back_to_defaults() {
|
||||
// .botsecrets is malformed — should still load with empty policy (fail-open)
|
||||
let tmp = TempDir::new().unwrap();
|
||||
fs::write(tmp.path().join(".botsecrets"), "this is not valid toml [[[").unwrap();
|
||||
let policy = Policy::load(tmp.path()).unwrap();
|
||||
let target = tmp.path().join("anything.rs");
|
||||
fs::write(&target, "").unwrap();
|
||||
assert_eq!(policy.check(Op::Read, &target).unwrap(), Decision::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fermata_toml_works_as_alias() {
|
||||
// No .botsecrets, but fermata.toml exists — should be loaded
|
||||
let tmp = TempDir::new().unwrap();
|
||||
fs::write(
|
||||
tmp.path().join("fermata.toml"),
|
||||
r#"
|
||||
[policy.write]
|
||||
patterns = ["generated/**"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let policy = Policy::load(tmp.path()).unwrap();
|
||||
let target = tmp.path().join("generated/output.rs");
|
||||
fs::create_dir_all(target.parent().unwrap()).unwrap();
|
||||
fs::write(&target, "").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
policy.check(Op::Write, &target).unwrap(),
|
||||
Decision::Deny(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn botsecrets_takes_priority_over_fermata_toml() {
|
||||
// Both .botsecrets and fermata.toml exist.
|
||||
// .botsecrets blocks writes to vendor/**, fermata.toml blocks writes to src/**
|
||||
// Only .botsecrets rules should apply.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
fs::write(
|
||||
tmp.path().join(".botsecrets"),
|
||||
r#"
|
||||
[policy.write]
|
||||
patterns = ["vendor/**"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
tmp.path().join("fermata.toml"),
|
||||
r#"
|
||||
[policy.write]
|
||||
patterns = ["src/**"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let policy = Policy::load(tmp.path()).unwrap();
|
||||
|
||||
// vendor/** should be blocked (from .botsecrets)
|
||||
let vendor_target = tmp.path().join("vendor/lib.rs");
|
||||
fs::create_dir_all(vendor_target.parent().unwrap()).unwrap();
|
||||
fs::write(&vendor_target, "").unwrap();
|
||||
assert!(matches!(
|
||||
policy.check(Op::Write, &vendor_target).unwrap(),
|
||||
Decision::Deny(_)
|
||||
));
|
||||
|
||||
// src/** should NOT be blocked (fermata.toml was ignored because .botsecrets exists)
|
||||
let src_target = tmp.path().join("src/main.rs");
|
||||
fs::create_dir_all(src_target.parent().unwrap()).unwrap();
|
||||
fs::write(&src_target, "").unwrap();
|
||||
assert_eq!(
|
||||
policy.check(Op::Write, &src_target).unwrap(),
|
||||
Decision::Allow
|
||||
);
|
||||
}
|
||||
@@ -2,36 +2,36 @@ use dirigent_fermata::core::{Decision, Policy};
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn project_with(toml: &str) -> TempDir {
|
||||
fn project_with(botsecrets: &str) -> TempDir {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
fs::write(tmp.path().join("botignore.toml"), toml).unwrap();
|
||||
fs::write(tmp.path().join(".botsecrets"), botsecrets).unwrap();
|
||||
tmp
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_substring_blocks() {
|
||||
let tmp = project_with("[bash]\ndeny = [\"rm -rf /\"]\n");
|
||||
let tmp = project_with("[policy.bash]\ndeny = [\"rm -rf /\"]\n");
|
||||
let p = Policy::load(tmp.path()).unwrap();
|
||||
assert!(matches!(p.check_command("sudo rm -rf / now").unwrap(), Decision::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_glob_blocks() {
|
||||
let tmp = project_with("[bash]\ndeny = [\"git push --force*\"]\n");
|
||||
let tmp = project_with("[policy.bash]\ndeny = [\"git push --force*\"]\n");
|
||||
let p = Policy::load(tmp.path()).unwrap();
|
||||
assert!(matches!(p.check_command("git push --force-with-lease").unwrap(), Decision::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_returns_ask() {
|
||||
let tmp = project_with("[bash]\nask = [\"rm *\"]\n");
|
||||
let tmp = project_with("[policy.bash]\nask = [\"rm *\"]\n");
|
||||
let p = Policy::load(tmp.path()).unwrap();
|
||||
assert!(matches!(p.check_command("rm somefile").unwrap(), Decision::Ask(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allow_prefixes_allows() {
|
||||
let tmp = project_with("[bash]\nallow_prefixes = [\"make test\"]\n");
|
||||
let tmp = project_with("[policy.bash]\nallow_prefixes = [\"make test\"]\n");
|
||||
let p = Policy::load(tmp.path()).unwrap();
|
||||
assert_eq!(p.check_command("make test").unwrap(), Decision::Allow);
|
||||
assert_eq!(p.check_command("make test-unit").unwrap(), Decision::Allow);
|
||||
@@ -46,7 +46,7 @@ fn no_rules_means_allow() {
|
||||
|
||||
#[test]
|
||||
fn deny_takes_precedence_over_allow_prefix() {
|
||||
let tmp = project_with("[bash]\ndeny = [\"rm -rf /\"]\nallow_prefixes = [\"rm\"]\n");
|
||||
let tmp = project_with("[policy.bash]\ndeny = [\"rm -rf /\"]\nallow_prefixes = [\"rm\"]\n");
|
||||
let p = Policy::load(tmp.path()).unwrap();
|
||||
assert!(matches!(p.check_command("rm -rf /").unwrap(), Decision::Deny(_)));
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ use dirigent_fermata::core::{Decision, Op, Policy};
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_project(botignore: &str, toml_text: &str) -> TempDir {
|
||||
fn make_project(botignore: &str, botsecrets: &str) -> TempDir {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
fs::write(tmp.path().join(".botignore"), botignore).unwrap();
|
||||
if !toml_text.is_empty() {
|
||||
fs::write(tmp.path().join("botignore.toml"), toml_text).unwrap();
|
||||
if !botignore.is_empty() {
|
||||
fs::write(tmp.path().join(".botignore"), botignore).unwrap();
|
||||
}
|
||||
if !botsecrets.is_empty() {
|
||||
fs::write(tmp.path().join(".botsecrets"), botsecrets).unwrap();
|
||||
}
|
||||
tmp
|
||||
}
|
||||
@@ -42,8 +44,8 @@ fn unmatched_path_allowed() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_read_block_applies_only_to_read() {
|
||||
let tmp = make_project("", "[read]\npatterns = [\"secrets/**\"]\n");
|
||||
fn policy_read_block_applies_only_to_read() {
|
||||
let tmp = make_project("", "[policy.read]\npatterns = [\"secrets/**\"]\n");
|
||||
let policy = Policy::load(tmp.path()).unwrap();
|
||||
let target = tmp.path().join("secrets/key.pem");
|
||||
fs::create_dir_all(target.parent().unwrap()).unwrap();
|
||||
@@ -53,8 +55,8 @@ fn toml_read_block_applies_only_to_read() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_write_block_applies_only_to_write() {
|
||||
let tmp = make_project("", "[write]\npatterns = [\"vendor/**\"]\n");
|
||||
fn policy_write_block_applies_only_to_write() {
|
||||
let tmp = make_project("", "[policy.write]\npatterns = [\"vendor/**\"]\n");
|
||||
let policy = Policy::load(tmp.path()).unwrap();
|
||||
let target = tmp.path().join("vendor/lib.rs");
|
||||
fs::create_dir_all(target.parent().unwrap()).unwrap();
|
||||
|
||||
@@ -3,12 +3,11 @@ use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn finds_botignore_toml_first() {
|
||||
fn finds_fermata_toml() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
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.toml"), "").unwrap();
|
||||
fs::write(root.join("fermata.toml"), "").unwrap();
|
||||
fs::create_dir_all(root.join(".git")).unwrap();
|
||||
|
||||
let target = root.join("sub/deep/file.rs");
|
||||
@@ -19,11 +18,11 @@ fn finds_botignore_toml_first() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_dot_botignore_toml() {
|
||||
fn finds_botsecrets() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::create_dir_all(root.join("sub")).unwrap();
|
||||
fs::write(root.join(".botignore.toml"), "").unwrap();
|
||||
fs::write(root.join(".botsecrets"), "").unwrap();
|
||||
|
||||
let target = root.join("sub/file.rs");
|
||||
fs::write(&target, "").unwrap();
|
||||
@@ -110,7 +109,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.toml"), "").unwrap();
|
||||
fs::write(root.join("a/fermata.toml"), "").unwrap();
|
||||
|
||||
let target = root.join("a/b/c/file.rs");
|
||||
fs::write(&target, "").unwrap();
|
||||
|
||||
+24
-36
@@ -1,47 +1,35 @@
|
||||
use dirigent_fermata::core::toml_config::{BotignoreToml, OpRules, BashRules};
|
||||
use dirigent_fermata::core::toml_config::{OpRules, BashRules};
|
||||
|
||||
#[test]
|
||||
fn parses_full_config() {
|
||||
fn op_rules_deserialize() {
|
||||
let src = r#"patterns = [".env*", "secrets/**"]"#;
|
||||
let rules: OpRules = toml::from_str(src).unwrap();
|
||||
assert_eq!(rules.patterns, vec![".env*", "secrets/**"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn op_rules_default_is_empty() {
|
||||
let rules = OpRules::default();
|
||||
assert!(rules.patterns.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_rules_deserialize() {
|
||||
let src = r#"
|
||||
[read]
|
||||
patterns = [".env*", "secrets/**"]
|
||||
|
||||
[write]
|
||||
patterns = ["vendor/**", "*.lock"]
|
||||
|
||||
[bash]
|
||||
deny = ["rm -rf /", "git push --force*"]
|
||||
ask = ["rm:*"]
|
||||
allow_prefixes = ["make test", "git checkout:*"]
|
||||
"#;
|
||||
let cfg: BotignoreToml = toml::from_str(src).unwrap();
|
||||
assert_eq!(cfg.read.unwrap().patterns, vec![".env*", "secrets/**"]);
|
||||
assert_eq!(cfg.write.unwrap().patterns, vec!["vendor/**", "*.lock"]);
|
||||
let bash = cfg.bash.unwrap();
|
||||
assert_eq!(bash.deny, vec!["rm -rf /", "git push --force*"]);
|
||||
assert_eq!(bash.ask, vec!["rm:*"]);
|
||||
assert_eq!(bash.allow_prefixes, vec!["make test", "git checkout:*"]);
|
||||
let rules: BashRules = toml::from_str(src).unwrap();
|
||||
assert_eq!(rules.deny, vec!["rm -rf /", "git push --force*"]);
|
||||
assert_eq!(rules.ask, vec!["rm:*"]);
|
||||
assert_eq!(rules.allow_prefixes, vec!["make test", "git checkout:*"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_config_is_valid() {
|
||||
let cfg: BotignoreToml = toml::from_str("").unwrap();
|
||||
assert!(cfg.read.is_none());
|
||||
assert!(cfg.write.is_none());
|
||||
assert!(cfg.bash.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_from_disk_when_present() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(tmp.path().join("botignore.toml"), "[read]\npatterns = [\".env\"]\n").unwrap();
|
||||
let cfg = BotignoreToml::load(tmp.path()).unwrap();
|
||||
assert_eq!(cfg.read.unwrap().patterns, vec![".env"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_empty_when_missing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cfg = BotignoreToml::load(tmp.path()).unwrap();
|
||||
assert!(cfg.read.is_none());
|
||||
fn bash_rules_default_is_empty() {
|
||||
let rules = BashRules::default();
|
||||
assert!(rules.deny.is_empty());
|
||||
assert!(rules.ask.is_empty());
|
||||
assert!(rules.allow_prefixes.is_empty());
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ fn fixture() -> TempDir {
|
||||
let root = tmp.path();
|
||||
fs::write(root.join(".botignore"), ".env\n.env.*\nconf/cert/**\nconf/mitmproxy/**\n").unwrap();
|
||||
fs::write(
|
||||
root.join("botignore.toml"),
|
||||
root.join(".botsecrets"),
|
||||
r#"
|
||||
[read]
|
||||
[policy.read]
|
||||
patterns = [
|
||||
"conf/localtestsettings.yaml",
|
||||
"conf/localsettings.yaml",
|
||||
@@ -19,14 +19,14 @@ patterns = [
|
||||
".claude/self-reflections/**",
|
||||
]
|
||||
|
||||
[write]
|
||||
[policy.write]
|
||||
patterns = [
|
||||
"conf/localtestsettings.yaml",
|
||||
"conf/localsettings.yaml",
|
||||
"conf/default-secrets.yaml",
|
||||
]
|
||||
|
||||
[bash]
|
||||
[policy.bash]
|
||||
deny = ["localtestsettings.yaml", "localsettings.yaml", "default-secrets.yaml", ".env"]
|
||||
ask = ["rm *", "mv *"]
|
||||
allow_prefixes = ["make test"]
|
||||
@@ -103,7 +103,7 @@ fn bash_rm_somefile_asks() {
|
||||
|
||||
#[test]
|
||||
fn read_self_reflections_asks() {
|
||||
// Note: A.4 has self-reflections under "ask" — current toml schema uses `[read].patterns`
|
||||
// Note: A.4 has self-reflections under "ask" — current toml schema uses `[policy.read].patterns`
|
||||
// for hard reads. This documents the gap; once toml has a `[read].ask`, switch to Ask.
|
||||
let t = fixture();
|
||||
let p = Policy::load(t.path()).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user