🏗️ 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:
2026-05-26 01:10:07 +02:00
parent 77520819f6
commit 168aefd415
17 changed files with 571 additions and 423 deletions
+182
View File
@@ -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
);
}
+7 -7
View File
@@ -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(_)));
}
+10 -8
View File
@@ -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();
+5 -6
View File
@@ -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
View File
@@ -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());
}
+5 -5
View File
@@ -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();