chore: rename packages/ to crates/
Move all 29 workspace members from packages/<name>/ to crates/<name>/. Updates: workspace Cargo.toml (members + path deps), justfile, root CLAUDE.md, scripts/build/CARGO_INSTALL.md, docs/architecture/crates.md (renamed from packages.md), structural references in docs/architecture and docs/configuration, per-crate CLAUDE.md self-references. Historical plans, reports, and building/ docs are left untouched. No behavior change; just check-all stays green and fermata tests pass.
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
use assert_cmd::Command;
|
||||
use predicates::prelude::*;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn check_blocks_botignore_match() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fs::write(tmp.path().join(".botignore"), ".env\n").unwrap();
|
||||
let target = tmp.path().join(".env");
|
||||
fs::write(&target, "").unwrap();
|
||||
|
||||
Command::cargo_bin("fermata")
|
||||
.unwrap()
|
||||
.args(["check", "--op", "read", target.to_str().unwrap()])
|
||||
.assert()
|
||||
.failure()
|
||||
.code(1)
|
||||
.stdout(predicate::str::contains(".env"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_allows_unmatched() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fs::write(tmp.path().join(".botignore"), ".env\n").unwrap();
|
||||
let target = tmp.path().join("src.rs");
|
||||
fs::write(&target, "").unwrap();
|
||||
|
||||
Command::cargo_bin("fermata")
|
||||
.unwrap()
|
||||
.args(["check", "--op", "read", target.to_str().unwrap()])
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_emits_json_with_flag() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fs::write(tmp.path().join(".botignore"), ".env\n").unwrap();
|
||||
let target = tmp.path().join(".env");
|
||||
fs::write(&target, "").unwrap();
|
||||
|
||||
let out = Command::cargo_bin("fermata")
|
||||
.unwrap()
|
||||
.args(["check", "--op", "read", "--json", target.to_str().unwrap()])
|
||||
.assert()
|
||||
.failure()
|
||||
.get_output()
|
||||
.stdout
|
||||
.clone();
|
||||
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
|
||||
assert_eq!(v["kind"], "deny");
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
use assert_cmd::Command;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn hook_blocks_read_of_botignore_match() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fs::write(tmp.path().join(".botignore"), ".env\n").unwrap();
|
||||
let target = tmp.path().join(".env");
|
||||
fs::write(&target, "").unwrap();
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"tool_name": "Read",
|
||||
"tool_input": { "file_path": target.to_str().unwrap() }
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let out = Command::cargo_bin("fermata")
|
||||
.unwrap()
|
||||
.args(["hook", "--harness", "claude"])
|
||||
.write_stdin(payload)
|
||||
.assert()
|
||||
.success() // hook always exits 0
|
||||
.get_output()
|
||||
.stdout
|
||||
.clone();
|
||||
|
||||
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
|
||||
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
|
||||
assert!(v["hookSpecificOutput"]["permissionDecisionReason"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains(".env"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_allows_unrelated_read() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fs::write(tmp.path().join(".botignore"), ".env\n").unwrap();
|
||||
let target = tmp.path().join("src.rs");
|
||||
fs::write(&target, "").unwrap();
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"tool_name": "Read",
|
||||
"tool_input": { "file_path": target.to_str().unwrap() }
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let out = Command::cargo_bin("fermata")
|
||||
.unwrap()
|
||||
.args(["hook", "--harness", "claude"])
|
||||
.write_stdin(payload)
|
||||
.assert()
|
||||
.success()
|
||||
.get_output()
|
||||
.stdout
|
||||
.clone();
|
||||
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
|
||||
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "allow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_unknown_harness_errors() {
|
||||
Command::cargo_bin("fermata")
|
||||
.unwrap()
|
||||
.args(["hook", "--harness", "doesnotexist"])
|
||||
.write_stdin("{}")
|
||||
.assert()
|
||||
.code(2);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
use dirigent_fermata::core::botignore::BotignoreSet;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn matches_simple_pattern() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::write(root.join(".botignore"), ".env\nsecrets/\n").unwrap();
|
||||
|
||||
let set = BotignoreSet::load(root).unwrap();
|
||||
|
||||
let env = root.join(".env");
|
||||
fs::write(&env, "").unwrap();
|
||||
let m = set.matched(&env).unwrap();
|
||||
assert!(m.is_some(), ".env should be matched");
|
||||
assert_eq!(m.unwrap().pattern, ".env");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_match_unrelated_files() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::write(root.join(".botignore"), ".env\n").unwrap();
|
||||
|
||||
let set = BotignoreSet::load(root).unwrap();
|
||||
|
||||
let other = root.join("README.md");
|
||||
fs::write(&other, "").unwrap();
|
||||
assert!(set.matched(&other).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negation_pattern_excludes() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::write(root.join(".botignore"), "*.log\n!keep.log\n").unwrap();
|
||||
|
||||
let set = BotignoreSet::load(root).unwrap();
|
||||
|
||||
let blocked = root.join("foo.log");
|
||||
fs::write(&blocked, "").unwrap();
|
||||
assert!(set.matched(&blocked).unwrap().is_some());
|
||||
|
||||
let allowed = root.join("keep.log");
|
||||
fs::write(&allowed, "").unwrap();
|
||||
assert!(set.matched(&allowed).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_missing_botignore_is_ok() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let set = BotignoreSet::load(tmp.path()).unwrap();
|
||||
let any = tmp.path().join("anything.txt");
|
||||
std::fs::write(&any, "").unwrap();
|
||||
assert!(set.matched(&any).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_botignore_is_scoped_to_its_directory() {
|
||||
// A `.botignore` in a subdirectory only applies under that subdirectory,
|
||||
// matching gitignore semantics: a sibling file with the same name at the
|
||||
// root is NOT affected.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::create_dir_all(root.join("frontend")).unwrap();
|
||||
fs::write(root.join("frontend/.botignore"), "secret.key\n").unwrap();
|
||||
|
||||
let set = BotignoreSet::load(root).unwrap();
|
||||
|
||||
let blocked = root.join("frontend/secret.key");
|
||||
fs::write(&blocked, "").unwrap();
|
||||
let m = set
|
||||
.matched(&blocked)
|
||||
.unwrap()
|
||||
.expect("frontend/secret.key should match");
|
||||
let src = m.source.to_string_lossy().replace('\\', "/");
|
||||
assert!(
|
||||
src.ends_with("frontend/.botignore"),
|
||||
"Rule.source should point at the nested file; was {}",
|
||||
src,
|
||||
);
|
||||
|
||||
let unblocked = root.join("secret.key");
|
||||
fs::write(&unblocked, "").unwrap();
|
||||
assert!(
|
||||
set.matched(&unblocked).unwrap().is_none(),
|
||||
"top-level secret.key should NOT be matched (rule scoped to frontend/)",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_botignore_anchored_pattern_is_local() {
|
||||
// A leading `/` anchors the pattern to the directory of the .botignore
|
||||
// file it's declared in.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::create_dir_all(root.join("frontend")).unwrap();
|
||||
fs::write(root.join("frontend/.botignore"), "/secret.key\n").unwrap();
|
||||
|
||||
let set = BotignoreSet::load(root).unwrap();
|
||||
|
||||
let blocked = root.join("frontend/secret.key");
|
||||
fs::write(&blocked, "").unwrap();
|
||||
assert!(set.matched(&blocked).unwrap().is_some());
|
||||
|
||||
let unblocked = root.join("secret.key");
|
||||
fs::write(&unblocked, "").unwrap();
|
||||
assert!(
|
||||
set.matched(&unblocked).unwrap().is_none(),
|
||||
"anchored /secret.key should NOT match outside frontend/",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_botignore_overrides_root() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::write(root.join(".botignore"), "*.log\n").unwrap();
|
||||
fs::create_dir_all(root.join("logs")).unwrap();
|
||||
fs::write(root.join("logs/.botignore"), "!keep.log\n").unwrap();
|
||||
|
||||
let set = BotignoreSet::load(root).unwrap();
|
||||
|
||||
let blocked = root.join("logs/foo.log");
|
||||
fs::write(&blocked, "").unwrap();
|
||||
assert!(set.matched(&blocked).unwrap().is_some());
|
||||
|
||||
let kept = root.join("logs/keep.log");
|
||||
fs::write(&kept, "").unwrap();
|
||||
assert!(
|
||||
set.matched(&kept).unwrap().is_none(),
|
||||
"logs/.botignore should un-ignore keep.log",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
use dirigent_fermata::core::extract::{extract_path_candidates, Confidence};
|
||||
|
||||
#[test]
|
||||
fn extracts_absolute_unix_path() {
|
||||
let s = "the file is at /home/user/.env and was modified";
|
||||
let cs = extract_path_candidates(s);
|
||||
assert!(cs.iter().any(|c| c.text == "/home/user/.env" && c.confidence == Confidence::High));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_absolute_windows_path() {
|
||||
let s = r"see C:\Users\me\secret.toml for details";
|
||||
let cs = extract_path_candidates(s);
|
||||
assert!(cs.iter().any(|c| c.text == r"C:\Users\me\secret.toml" && c.confidence == Confidence::High));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_relative_with_separator() {
|
||||
let s = "modified src/lib.rs and tests/foo.rs";
|
||||
let cs = extract_path_candidates(s);
|
||||
let texts: Vec<_> = cs.iter().map(|c| c.text.as_str()).collect();
|
||||
assert!(texts.contains(&"src/lib.rs"));
|
||||
assert!(texts.contains(&"tests/foo.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_filename_with_extension_is_low_confidence() {
|
||||
let s = "open README.md please";
|
||||
let cs = extract_path_candidates(s);
|
||||
let r = cs.iter().find(|c| c.text == "README.md").unwrap();
|
||||
assert_eq!(r.confidence, Confidence::Low);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_pure_words() {
|
||||
let s = "the quick brown fox";
|
||||
let cs = extract_path_candidates(s);
|
||||
assert!(cs.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use dirigent_fermata::core::{Decision, Op, Reason, Rule};
|
||||
|
||||
#[test]
|
||||
fn op_variants_exist() {
|
||||
let _ = Op::Read;
|
||||
let _ = Op::Write;
|
||||
let _ = Op::Execute;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_allow_is_simple() {
|
||||
let d = Decision::Allow;
|
||||
assert!(matches!(d, Decision::Allow));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_deny_carries_reason() {
|
||||
let rule = Rule {
|
||||
source: "/proj/.botignore".into(),
|
||||
pattern: ".env".into(),
|
||||
};
|
||||
let d = Decision::Deny(Reason {
|
||||
message: "blocked by .botignore".into(),
|
||||
rule: Some(rule),
|
||||
});
|
||||
match d {
|
||||
Decision::Deny(r) => {
|
||||
assert_eq!(r.message, "blocked by .botignore");
|
||||
assert!(r.rule.is_some());
|
||||
}
|
||||
_ => panic!("expected Deny"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_ask_carries_reason() {
|
||||
let d = Decision::Ask(Reason {
|
||||
message: "needs confirmation".into(),
|
||||
rule: None,
|
||||
});
|
||||
assert!(matches!(d, Decision::Ask(_)));
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use dirigent_fermata::core::{Decision, Policy};
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn project_with(toml: &str) -> TempDir {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
fs::write(tmp.path().join("botignore.toml"), toml).unwrap();
|
||||
tmp
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_substring_blocks() {
|
||||
let tmp = project_with("[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 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 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 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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_rules_means_allow() {
|
||||
let tmp = project_with("");
|
||||
let p = Policy::load(tmp.path()).unwrap();
|
||||
assert_eq!(p.check_command("anything goes").unwrap(), Decision::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_takes_precedence_over_allow_prefix() {
|
||||
let tmp = project_with("[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(_)));
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
use dirigent_fermata::core::{Decision, Op, Policy};
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_project(botignore: &str, toml_text: &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();
|
||||
}
|
||||
tmp
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn botignore_blocks_read() {
|
||||
let tmp = make_project(".env\n", "");
|
||||
let policy = Policy::load(tmp.path()).unwrap();
|
||||
let target = tmp.path().join(".env");
|
||||
fs::write(&target, "").unwrap();
|
||||
let d = policy.check(Op::Read, &target).unwrap();
|
||||
assert!(matches!(d, Decision::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn botignore_blocks_write_too() {
|
||||
let tmp = make_project(".env\n", "");
|
||||
let policy = Policy::load(tmp.path()).unwrap();
|
||||
let target = tmp.path().join(".env");
|
||||
let d = policy.check(Op::Write, &target).unwrap();
|
||||
assert!(matches!(d, Decision::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmatched_path_allowed() {
|
||||
let tmp = make_project(".env\n", "");
|
||||
let policy = Policy::load(tmp.path()).unwrap();
|
||||
let target = tmp.path().join("src/main.rs");
|
||||
fs::create_dir_all(target.parent().unwrap()).unwrap();
|
||||
fs::write(&target, "").unwrap();
|
||||
let d = policy.check(Op::Read, &target).unwrap();
|
||||
assert_eq!(d, Decision::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_read_block_applies_only_to_read() {
|
||||
let tmp = make_project("", "[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();
|
||||
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 toml_write_block_applies_only_to_write() {
|
||||
let tmp = make_project("", "[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();
|
||||
fs::write(&target, "").unwrap();
|
||||
assert_eq!(policy.check(Op::Read, &target).unwrap(), Decision::Allow);
|
||||
assert!(matches!(policy.check(Op::Write, &target).unwrap(), Decision::Deny(_)));
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
use dirigent_fermata::core::project::find_project_root;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn finds_botignore_toml_first() {
|
||||
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"), "").unwrap();
|
||||
fs::create_dir_all(root.join(".git")).unwrap();
|
||||
|
||||
let target = root.join("sub/deep/file.rs");
|
||||
fs::write(&target, "").unwrap();
|
||||
|
||||
let found = find_project_root(&target).unwrap();
|
||||
assert_eq!(found, root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_botignore() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::create_dir_all(root.join("sub")).unwrap();
|
||||
fs::write(root.join(".botignore"), "").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 falls_back_to_git() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::create_dir_all(root.join("sub")).unwrap();
|
||||
fs::create_dir_all(root.join(".git")).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 returns_none_when_no_marker() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let target = tmp.path().join("file.rs");
|
||||
std::fs::write(&target, "").unwrap();
|
||||
assert!(find_project_root(&target).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
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();
|
||||
|
||||
let target = root.join("a/b/c/file.rs");
|
||||
fs::write(&target, "").unwrap();
|
||||
|
||||
let found = find_project_root(&target).unwrap();
|
||||
assert_eq!(found, root.join("a"));
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use dirigent_fermata::core::toml_config::{BotignoreToml, OpRules, BashRules};
|
||||
|
||||
#[test]
|
||||
fn parses_full_config() {
|
||||
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:*"]);
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Smoke-test contract from `docs/workpad/brainstorm/fermata.md` Appendix A.4.
|
||||
|
||||
use dirigent_fermata::core::{Decision, Op, Policy};
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn fixture() -> TempDir {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::write(root.join(".botignore"), ".env\n.env.*\nconf/cert/**\nconf/mitmproxy/**\n").unwrap();
|
||||
fs::write(
|
||||
root.join("botignore.toml"),
|
||||
r#"
|
||||
[read]
|
||||
patterns = [
|
||||
"conf/localtestsettings.yaml",
|
||||
"conf/localsettings.yaml",
|
||||
"conf/default-secrets.yaml",
|
||||
".claude/self-reflections/**",
|
||||
]
|
||||
|
||||
[write]
|
||||
patterns = [
|
||||
"conf/localtestsettings.yaml",
|
||||
"conf/localsettings.yaml",
|
||||
"conf/default-secrets.yaml",
|
||||
]
|
||||
|
||||
[bash]
|
||||
deny = ["localtestsettings.yaml", "localsettings.yaml", "default-secrets.yaml", ".env"]
|
||||
ask = ["rm *", "mv *"]
|
||||
allow_prefixes = ["make test"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
fs::create_dir_all(root.join("conf")).unwrap();
|
||||
fs::create_dir_all(root.join("datatap")).unwrap();
|
||||
fs::create_dir_all(root.join(".claude/self-reflections")).unwrap();
|
||||
for f in [".env", "conf/localsettings.yaml", "datatap/foo.py", ".claude/self-reflections/x.md"] {
|
||||
fs::write(root.join(f), "").unwrap();
|
||||
}
|
||||
tmp
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_dot_env_denied() {
|
||||
let t = fixture();
|
||||
let p = Policy::load(t.path()).unwrap();
|
||||
assert!(matches!(p.check(Op::Read, &t.path().join(".env")).unwrap(), Decision::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_cat_dot_env_denied() {
|
||||
let t = fixture();
|
||||
let p = Policy::load(t.path()).unwrap();
|
||||
assert!(matches!(p.check_command("cat ./.env").unwrap(), Decision::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_rm_localsettings_denied() {
|
||||
let t = fixture();
|
||||
let p = Policy::load(t.path()).unwrap();
|
||||
assert!(matches!(
|
||||
p.check_command("rm ./conf/localsettings.yaml").unwrap(),
|
||||
Decision::Deny(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_localsettings_denied() {
|
||||
let t = fixture();
|
||||
let p = Policy::load(t.path()).unwrap();
|
||||
assert!(matches!(
|
||||
p.check(Op::Write, &t.path().join("conf/localsettings.yaml")).unwrap(),
|
||||
Decision::Deny(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_datatap_foo_py_allowed() {
|
||||
let t = fixture();
|
||||
let p = Policy::load(t.path()).unwrap();
|
||||
assert_eq!(
|
||||
p.check(Op::Write, &t.path().join("datatap/foo.py")).unwrap(),
|
||||
Decision::Allow
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_make_test_allowed() {
|
||||
let t = fixture();
|
||||
let p = Policy::load(t.path()).unwrap();
|
||||
assert_eq!(p.check_command("make test").unwrap(), Decision::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_rm_somefile_asks() {
|
||||
let t = fixture();
|
||||
let p = Policy::load(t.path()).unwrap();
|
||||
assert!(matches!(p.check_command("rm somefile").unwrap(), Decision::Ask(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_self_reflections_asks() {
|
||||
// Note: A.4 has self-reflections under "ask" — current toml schema uses `[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();
|
||||
let d = p.check(Op::Read, &t.path().join(".claude/self-reflections/x.md")).unwrap();
|
||||
assert!(matches!(d, Decision::Deny(_)));
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use dirigent_fermata::core::{Decision, Reason};
|
||||
use dirigent_fermata::harness::{HarnessAdapter, PathKind, ToolOp};
|
||||
use dirigent_fermata::harness::claude::ClaudeAdapter;
|
||||
|
||||
#[test]
|
||||
fn parses_read_payload() {
|
||||
let payload = br#"{"tool_name":"Read","tool_input":{"file_path":"/proj/.env"}}"#;
|
||||
let call = ClaudeAdapter.parse_request(payload).unwrap();
|
||||
assert_eq!(call.tool_name, "Read");
|
||||
match call.op {
|
||||
ToolOp::Path { path, kind } => {
|
||||
assert_eq!(path.to_string_lossy(), "/proj/.env");
|
||||
assert_eq!(kind, PathKind::Read);
|
||||
}
|
||||
_ => panic!("expected Path op"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_write_payload() {
|
||||
let payload = br#"{"tool_name":"Write","tool_input":{"file_path":"/proj/out.txt"}}"#;
|
||||
let call = ClaudeAdapter.parse_request(payload).unwrap();
|
||||
assert!(matches!(call.op, ToolOp::Path { kind: PathKind::Write, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_edit_as_write() {
|
||||
let payload = br#"{"tool_name":"Edit","tool_input":{"file_path":"/proj/src.rs"}}"#;
|
||||
let call = ClaudeAdapter.parse_request(payload).unwrap();
|
||||
assert!(matches!(call.op, ToolOp::Path { kind: PathKind::Write, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_multiedit_as_write() {
|
||||
let payload = br#"{"tool_name":"MultiEdit","tool_input":{"file_path":"/proj/src.rs","edits":[]}}"#;
|
||||
let call = ClaudeAdapter.parse_request(payload).unwrap();
|
||||
assert!(matches!(call.op, ToolOp::Path { kind: PathKind::Write, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_bash_payload() {
|
||||
let payload = br#"{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}"#;
|
||||
let call = ClaudeAdapter.parse_request(payload).unwrap();
|
||||
match call.op {
|
||||
ToolOp::Command { text } => assert_eq!(text, "rm -rf /"),
|
||||
_ => panic!("expected Command op"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_deny_as_hookspecificoutput() {
|
||||
let payload = br#"{"tool_name":"Read","tool_input":{"file_path":"/proj/.env"}}"#;
|
||||
let call = ClaudeAdapter.parse_request(payload).unwrap();
|
||||
let d = Decision::Deny(Reason {
|
||||
message: "blocked by .botignore: .env".into(),
|
||||
rule: None,
|
||||
});
|
||||
let out = ClaudeAdapter.render_decision(&call, &d).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
|
||||
assert_eq!(v["hookSpecificOutput"]["hookEventName"], "PreToolUse");
|
||||
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
|
||||
assert!(v["hookSpecificOutput"]["permissionDecisionReason"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains(".env"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_allow_as_allow() {
|
||||
let payload = br#"{"tool_name":"Read","tool_input":{"file_path":"/proj/src/main.rs"}}"#;
|
||||
let call = ClaudeAdapter.parse_request(payload).unwrap();
|
||||
let out = ClaudeAdapter.render_decision(&call, &Decision::Allow).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
|
||||
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "allow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_ask_as_ask() {
|
||||
let payload = br#"{"tool_name":"Bash","tool_input":{"command":"rm something"}}"#;
|
||||
let call = ClaudeAdapter.parse_request(payload).unwrap();
|
||||
let out = ClaudeAdapter
|
||||
.render_decision(&call, &Decision::Ask(Reason { message: "confirm".into(), rule: None }))
|
||||
.unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
|
||||
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "ask");
|
||||
}
|
||||
Reference in New Issue
Block a user