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:
2026-04-30 21:58:57 +02:00
commit 97001e1544
26 changed files with 1678 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
# Package: dirigent_fermata
Harness-agnostic policy gate for AI coding agents.
## Quick Facts
- **Type**: Library + binary (`fermata`)
- **Main Entry**: `src/lib.rs`, `src/bin/fermata.rs`
- **Dependencies**: `ignore`, `toml`, `regex`, `globset`, `serde`, `clap` (cli feature)
- **Status**: v0.1 — library + CLI + Claude hook adapter
## Layering
Three concentric layers; nothing inner imports from anything outer.
- **`core/`** — harness-unaware, transport-unaware, sync. Types (`Op`, `Decision`), `.botignore` walker, `botignore.toml` parser, `Policy::check` / `check_command`, path extraction. Sync, no tokio.
- **`harness/`** — `HarnessAdapter` trait over a normalized `ToolCall`. Each adapter (Claude, future Codex, etc.) lives in its own submodule, feature-gated.
- **`bin/fermata.rs`** — only place where `clap`, stdio, and exit codes appear.
## Release Model
Developed in this monorepo; planned to be exported as a standalone repo in the future for advertising / external distribution. Development stays here. See `docs/tools/fermata.md`.
## Dependency Direction
`dirigent_tools` depends on `dirigent_fermata`, never the reverse. Fermata must remain usable as a standalone hook/MCP without dragging in the in-process ACP tool runtime.
## Out of scope (v0.1)
Codex / Gemini hook adapters, MCP server mode, PostToolUse envelope, `readonly_only` Bash mode, audit log, filesystem watcher. Each is a future task with its own plan.
## See also
- `docs/tools/fermata.md` — Dirigent integration plan
- `docs/workpad/brainstorm/fermata.md` — canonical product spec
+37
View File
@@ -0,0 +1,37 @@
[package]
name = "dirigent_fermata"
version = "0.1.0"
edition = "2021"
description = "Harness-agnostic policy gate for AI coding agents (.botignore + botignore.toml)"
[lib]
path = "src/lib.rs"
[[bin]]
name = "fermata"
path = "src/bin/fermata.rs"
required-features = ["cli"]
[dependencies]
globset = "0.4"
ignore = "0.4"
walkdir = "2"
toml = "0.8"
regex = "1.10"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "2.0"
clap = { version = "4.5", features = ["derive"], optional = true }
[dev-dependencies]
tempfile = "3.10"
assert_cmd = "2.0"
predicates = "3.1"
[features]
default = ["cli", "harness-claude"]
cli = ["dep:clap"]
harness-claude = []
[lints]
workspace = true
+53
View File
@@ -0,0 +1,53 @@
# dirigent_fermata
`𝄐 fermata` — a fast, harness-agnostic guard that blocks AI coding agents from reading, writing, or executing things they shouldn't.
Reads `.botignore` (gitignore syntax) and an optional `botignore.toml` for advanced rules. Designed to be called from agent hooks, used as an MCP server (future), or consumed as a library.
## Status
v0.1 — first releasable slice:
- Library: `Op`, `Decision`, `Policy::check`, `Policy::check_command`, project-root walk-up, `.botignore` walker (via `ignore`), `botignore.toml` parsing, path identification heuristics.
- CLI: `fermata check <path>...`, `fermata hook --harness <name>`.
- Harness: Claude Code (PreToolUse) only.
Out of scope for v0.1: Codex, Gemini, MCP server, audit log, filesystem watcher.
## Quick start
```bash
# As a CLI
fermata check --op read /path/to/.env
echo $? # 1 if blocked, 0 if allowed
# As a Claude Code hook
fermata hook --harness claude < hook_payload.json
```
## Configuration
`.botignore` (gitignore syntax, applies to read + write):
```
.env
.env.*
secrets/**
```
`botignore.toml` (per-op rules):
```toml
[read]
patterns = [".env*", "secrets/**"]
[write]
patterns = ["vendor/**", "*.lock"]
[bash]
deny = ["rm -rf /", "git push --force*"]
ask = ["rm:*", "mv:*"]
allow_prefixes = ["make test", "git checkout:*"]
```
## See also
- `docs/tools/fermata.md` — Dirigent integration plan
- `docs/workpad/brainstorm/fermata.md` — full product spec
+205
View File
@@ -0,0 +1,205 @@
use clap::{Parser, Subcommand, ValueEnum};
use dirigent_fermata::core::{project::find_project_root, Decision, Op, Policy};
use std::io::{Read, Write};
use std::path::PathBuf;
use std::process::ExitCode;
#[derive(Parser)]
#[command(name = "fermata", about = "Harness-agnostic policy gate for AI coding agents")]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Check whether `path` is allowed for the given `--op`.
Check {
#[arg(long, value_enum, default_value_t = OpArg::Read)]
op: OpArg,
#[arg(long)]
json: bool,
paths: Vec<PathBuf>,
},
/// Read a harness hook payload from stdin and render the decision.
Hook {
#[arg(long)]
harness: String,
},
}
#[derive(Copy, Clone, ValueEnum)]
enum OpArg {
Read,
Write,
Execute,
}
impl From<OpArg> for Op {
fn from(a: OpArg) -> Self {
match a {
OpArg::Read => Op::Read,
OpArg::Write => Op::Write,
OpArg::Execute => Op::Execute,
}
}
}
fn main() -> ExitCode {
let cli = Cli::parse();
match cli.cmd {
Cmd::Check { op, json, paths } => run_check(op.into(), json, &paths),
Cmd::Hook { harness } => run_hook(&harness),
}
}
fn run_check(op: Op, json: bool, paths: &[PathBuf]) -> ExitCode {
let mut worst: Option<Decision> = None;
for p in paths {
let root = match find_project_root(p) {
Some(r) => r,
None => continue,
};
let policy = match Policy::load(&root) {
Ok(p) => p,
Err(e) => {
eprintln!("fermata: load error: {e}");
return ExitCode::from(2);
}
};
let d = match policy.check(op, p) {
Ok(d) => d,
Err(e) => {
eprintln!("fermata: check error: {e}");
return ExitCode::from(2);
}
};
worst = Some(merge_worst(worst.take(), d));
}
let decision = worst.unwrap_or(Decision::Allow);
if json {
let _ = serde_json::to_writer(std::io::stdout().lock(), &decision);
let _ = writeln!(std::io::stdout().lock());
} else if let Decision::Deny(ref r) = decision {
println!("{}", r.message);
} else if let Decision::Ask(ref r) = decision {
println!("ASK: {}", r.message);
}
match decision {
Decision::Allow => ExitCode::from(0),
Decision::Ask(_) => ExitCode::from(0),
Decision::Deny(_) => ExitCode::from(1),
}
}
fn run_hook(harness: &str) -> ExitCode {
let adapter = match dirigent_fermata::harness::lookup(harness) {
Some(a) => a,
None => {
eprintln!("fermata: unknown harness '{harness}'");
return ExitCode::from(2);
}
};
let mut buf = Vec::new();
if let Err(e) = std::io::stdin().lock().read_to_end(&mut buf) {
eprintln!("fermata: stdin: {e}");
return ExitCode::from(2);
}
let call = match adapter.parse_request(&buf) {
Ok(c) => c,
Err(e) => {
eprintln!("fermata: parse: {e}");
return ExitCode::from(2);
}
};
use dirigent_fermata::harness::{PathKind, ToolOp};
let decision = match &call.op {
ToolOp::Path { path, kind } => {
let root = match find_project_root(path) {
// No project root → fail-open allow (hook must always exit 0 with a verdict).
// run_check silently skips these paths; here we must still emit JSON.
Some(r) => r,
None => {
let out = adapter.render_decision(&call, &Decision::Allow).unwrap_or_default();
let _ = std::io::stdout().lock().write_all(&out);
return ExitCode::from(0);
}
};
let policy = match Policy::load(&root) {
Ok(p) => p,
Err(e) => {
eprintln!("fermata: load error: {e}");
let out = adapter.render_decision(&call, &Decision::Allow).unwrap_or_default();
let _ = std::io::stdout().lock().write_all(&out);
return ExitCode::from(0);
}
};
let op = match kind {
PathKind::Read => Op::Read,
PathKind::Write => Op::Write,
};
match policy.check(op, path) {
Ok(d) => d,
Err(e) => {
eprintln!("fermata: check error: {e}");
let out = adapter.render_decision(&call, &Decision::Allow).unwrap_or_default();
let _ = std::io::stdout().lock().write_all(&out);
return ExitCode::from(0);
}
}
}
ToolOp::Command { text } => {
// For commands, we look up the project from cwd (no path argument).
let cwd = match std::env::current_dir() {
Ok(d) => d,
Err(e) => {
eprintln!("fermata: cwd error: {e}");
let out = adapter.render_decision(&call, &Decision::Allow).unwrap_or_default();
let _ = std::io::stdout().lock().write_all(&out);
return ExitCode::from(0);
}
};
match find_project_root(&cwd) {
// No project root → fail-open allow (see Path branch note above).
None => Decision::Allow,
Some(root) => {
let policy = match Policy::load(&root) {
Ok(p) => p,
Err(e) => {
eprintln!("fermata: load error: {e}");
let out = adapter.render_decision(&call, &Decision::Allow).unwrap_or_default();
let _ = std::io::stdout().lock().write_all(&out);
return ExitCode::from(0);
}
};
match policy.check_command(text) {
Ok(d) => d,
Err(e) => {
eprintln!("fermata: check error: {e}");
let out = adapter.render_decision(&call, &Decision::Allow).unwrap_or_default();
let _ = std::io::stdout().lock().write_all(&out);
return ExitCode::from(0);
}
}
}
}
}
};
let out = adapter.render_decision(&call, &decision).unwrap_or_default();
let _ = std::io::stdout().lock().write_all(&out);
ExitCode::from(0) // hook bins always exit 0; the JSON carries the verdict
}
fn merge_worst(a: Option<Decision>, b: Decision) -> Decision {
let rank = |d: &Decision| match d {
Decision::Allow => 0,
Decision::Ask(_) => 1,
Decision::Deny(_) => 2,
};
match a {
None => b,
Some(a) if rank(&a) >= rank(&b) => a,
Some(_) => b,
}
}
+91
View File
@@ -0,0 +1,91 @@
use crate::core::decision::Rule;
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum BotignoreError {
#[error("failed to read .botignore: {0}")]
Io(#[from] std::io::Error),
#[error("failed to compile .botignore: {0}")]
Compile(#[source] ignore::Error),
}
struct ScopedMatcher {
/// Path of the source `.botignore` file.
source: PathBuf,
/// Directory the matcher is rooted at (parent of `source`).
dir: PathBuf,
/// Depth of `dir` (component count) — deeper = more specific.
depth: usize,
matcher: Gitignore,
}
/// A collection of `.botignore` matchers, one per file discovered under the
/// project root. Each matcher is rooted at its source file's directory so
/// gitignore-style semantics (anchored vs unanchored patterns, per-directory
/// scope) work correctly. At match time, the deepest applicable matcher
/// wins; whitelist (`!` negation) at any depth overrides an ignore at
/// shallower depth.
pub struct BotignoreSet {
matchers: Vec<ScopedMatcher>,
}
impl BotignoreSet {
/// Walk `root` recursively, building a per-file matcher for every
/// `.botignore` encountered. Empty if none are found.
pub fn load(root: &Path) -> Result<Self, BotignoreError> {
let mut matchers = Vec::new();
for entry in walkdir::WalkDir::new(root).into_iter().filter_map(Result::ok) {
if !(entry.file_type().is_file() && entry.file_name() == ".botignore") {
continue;
}
let source = entry.path().to_path_buf();
let dir = source.parent().unwrap_or(root).to_path_buf();
let mut builder = GitignoreBuilder::new(&dir);
if let Some(err) = builder.add(&source) {
return Err(BotignoreError::Compile(err));
}
let matcher = builder.build().map_err(BotignoreError::Compile)?;
let depth = dir.components().count();
matchers.push(ScopedMatcher {
source,
dir,
depth,
matcher,
});
}
// Shallowest first so iteration applies broader rules then more-specific overrides.
matchers.sort_by_key(|m| m.depth);
Ok(Self { matchers })
}
/// Returns `Some(Rule)` if `path` is matched (and not negated by a
/// deeper-scoped whitelist), else `None`. The deepest matcher whose
/// directory contains `path` wins.
pub fn matched(&self, path: &Path) -> Result<Option<Rule>, BotignoreError> {
let is_dir = path.is_dir();
let mut current: Option<&ScopedMatcher> = None;
let mut current_pattern: Option<String> = None;
for sm in &self.matchers {
if !path.starts_with(&sm.dir) {
continue;
}
let m = sm.matcher.matched(path, is_dir);
if m.is_ignore() {
current = Some(sm);
current_pattern = m.inner().map(|g| g.original().to_string());
} else if m.is_whitelist() {
// Deeper whitelist overrides any shallower ignore.
current = None;
current_pattern = None;
}
}
Ok(current.map(|sm| Rule {
source: sm.source.clone(),
pattern: current_pattern.unwrap_or_default(),
}))
}
}
+30
View File
@@ -0,0 +1,30 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Rule {
/// Source file the rule came from (e.g. `/proj/.botignore`).
pub source: PathBuf,
/// Pattern text as it appeared in the source.
pub pattern: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Reason {
pub message: String,
pub rule: Option<Rule>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum Decision {
Allow,
Ask(Reason),
Deny(Reason),
}
impl Decision {
pub fn is_blocking(&self) -> bool {
matches!(self, Decision::Deny(_))
}
}
+50
View File
@@ -0,0 +1,50 @@
use regex::Regex;
use std::sync::OnceLock;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Confidence {
/// Absolute path or path with explicit separator.
High,
/// Bare filename with extension; could be a word.
Low,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PathCandidate {
pub text: String,
pub confidence: Confidence,
}
/// Heuristically extract path-like substrings from arbitrary text.
/// Confident matches (absolute paths, paths containing separators) → `High`.
/// Bare filenames with an extension → `Low` (advisory only).
pub fn extract_path_candidates(text: &str) -> Vec<PathCandidate> {
static UNIX_ABS: OnceLock<Regex> = OnceLock::new();
static WIN_ABS: OnceLock<Regex> = OnceLock::new();
static REL_WITH_SEP: OnceLock<Regex> = OnceLock::new();
static BARE_NAME: OnceLock<Regex> = OnceLock::new();
let unix_abs = UNIX_ABS.get_or_init(|| Regex::new(r"(?m)(?:^|\s)(/[\w./~\-_]+)").unwrap());
let win_abs = WIN_ABS.get_or_init(|| Regex::new(r#"(?m)(?:^|\s)([A-Za-z]:\\[\w.\\\-_]+)"#).unwrap());
let rel = REL_WITH_SEP.get_or_init(|| Regex::new(r"(?m)(?:^|\s)((?:\./|\.\./|[\w\-_]+/)[\w./\-_]+)").unwrap());
let bare = BARE_NAME.get_or_init(|| Regex::new(r"(?m)(?:^|\s)([\w\-_]+\.[A-Za-z]{1,8})(?:\s|[.,;:!?]|$)").unwrap());
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
for re in [unix_abs, win_abs, rel] {
for cap in re.captures_iter(text) {
let m = cap.get(1).unwrap().as_str().trim_end_matches(['.', ',', ';', ':', '!', '?']);
if seen.insert(m.to_string()) {
out.push(PathCandidate { text: m.to_string(), confidence: Confidence::High });
}
}
}
for cap in bare.captures_iter(text) {
let m = cap.get(1).unwrap().as_str();
if seen.insert(m.to_string()) {
out.push(PathCandidate { text: m.to_string(), confidence: Confidence::Low });
}
}
out
}
+14
View File
@@ -0,0 +1,14 @@
//! Core policy layer. Harness-unaware, transport-unaware, sync.
pub mod botignore;
pub mod decision;
pub mod extract;
pub mod op;
pub mod policy;
pub mod project;
pub mod toml_config;
pub use decision::{Decision, Reason, Rule};
pub use extract::{extract_path_candidates, Confidence, PathCandidate};
pub use op::Op;
pub use policy::Policy;
+9
View File
@@ -0,0 +1,9 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Op {
Read,
Write,
Execute,
}
+164
View File
@@ -0,0 +1,164 @@
use crate::core::botignore::{BotignoreError, BotignoreSet};
use crate::core::decision::{Decision, Reason, Rule};
use crate::core::op::Op;
use crate::core::toml_config::{BotignoreToml, TomlConfigError};
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum PolicyError {
#[error(transparent)]
Botignore(#[from] BotignoreError),
#[error(transparent)]
Toml(#[from] TomlConfigError),
#[error("invalid pattern in botignore.toml: {0}")]
BadPattern(String),
}
pub struct Policy {
root: PathBuf,
botignore: BotignoreSet,
toml: BotignoreToml,
read_globs: globset::GlobSet,
write_globs: globset::GlobSet,
read_patterns: Vec<String>,
write_patterns: Vec<String>,
}
impl Policy {
pub fn load(root: &Path) -> Result<Self, PolicyError> {
let botignore = BotignoreSet::load(root)?;
let toml = BotignoreToml::load(root)?;
let (read_globs, read_patterns) = compile_globs(
toml.read.as_ref().map(|r| r.patterns.as_slice()).unwrap_or(&[]),
)?;
let (write_globs, write_patterns) = compile_globs(
toml.write.as_ref().map(|r| r.patterns.as_slice()).unwrap_or(&[]),
)?;
Ok(Self {
root: root.to_path_buf(),
botignore,
toml,
read_globs,
write_globs,
read_patterns,
write_patterns,
})
}
pub fn check_command(&self, command: &str) -> Result<Decision, PolicyError> {
let bash = match self.toml.bash.as_ref() {
Some(b) => b,
None => return Ok(Decision::Allow),
};
// 1. Deny wins over everything else.
if let Some(pat) = match_command(command, &bash.deny)? {
return Ok(Decision::Deny(Reason {
message: format!("blocked by botignore.toml [bash.deny]: {}", pat),
rule: Some(Rule {
source: self.root.join("botignore.toml"),
pattern: pat,
}),
}));
}
// 2. Allow prefixes — if any matches, allow.
for prefix in &bash.allow_prefixes {
if command_matches_prefix(command, prefix) {
return Ok(Decision::Allow);
}
}
// 3. Ask patterns.
if let Some(pat) = match_command(command, &bash.ask)? {
return Ok(Decision::Ask(Reason {
message: format!("requires confirmation [bash.ask]: {}", pat),
rule: Some(Rule {
source: self.root.join("botignore.toml"),
pattern: pat,
}),
}));
}
Ok(Decision::Allow)
}
pub fn check(&self, op: Op, path: &Path) -> Result<Decision, PolicyError> {
// 1. .botignore is path-only and applies to read+write equally.
if matches!(op, Op::Read | Op::Write) {
if let Some(rule) = self.botignore.matched(path)? {
return Ok(Decision::Deny(Reason {
message: format!("blocked by .botignore: {}", rule.pattern),
rule: Some(rule),
}));
}
}
// 2. botignore.toml namespace-specific rules.
let (set, patterns) = match op {
Op::Read => (&self.read_globs, &self.read_patterns),
Op::Write => (&self.write_globs, &self.write_patterns),
Op::Execute => return Ok(Decision::Allow), // path-based check_command handles bash
};
let rel = path.strip_prefix(&self.root).unwrap_or(path);
let matches = set.matches(rel);
if let Some(idx) = matches.first() {
let pattern = patterns[*idx].clone();
return Ok(Decision::Deny(Reason {
message: format!("blocked by botignore.toml [{:?}]: {}", op, pattern),
rule: Some(Rule {
source: self.root.join("botignore.toml"),
pattern,
}),
}));
}
Ok(Decision::Allow)
}
}
/// Substring-or-glob match of `command` against `patterns`.
/// Patterns containing glob metachars (`*`, `?`, `[`) are treated as globs;
/// others are matched as literal substrings.
fn match_command(command: &str, patterns: &[String]) -> Result<Option<String>, PolicyError> {
for pat in patterns {
if is_glob(pat) {
let g = globset::Glob::new(pat)
.map_err(|e| PolicyError::BadPattern(e.to_string()))?
.compile_matcher();
if g.is_match(command) {
return Ok(Some(pat.clone()));
}
} else if command.contains(pat.as_str()) {
return Ok(Some(pat.clone()));
}
}
Ok(None)
}
fn is_glob(pat: &str) -> bool {
pat.contains('*') || pat.contains('?') || pat.contains('[')
}
/// `prefix` is `"name"` or `"name:*"`. Both treat `name` as a leading word
/// boundary in `command`. Mirrors Claude Code's `Bash(name:*)` style.
fn command_matches_prefix(command: &str, prefix: &str) -> bool {
let needle = prefix.trim_end_matches(":*");
command.trim_start().starts_with(needle)
}
fn compile_globs(patterns: &[String]) -> Result<(globset::GlobSet, Vec<String>), PolicyError> {
let mut builder = globset::GlobSetBuilder::new();
for pat in patterns {
let glob = globset::Glob::new(pat).map_err(|e| PolicyError::BadPattern(e.to_string()))?;
builder.add(glob);
}
let set = builder
.build()
.map_err(|e| PolicyError::BadPattern(e.to_string()))?;
Ok((set, patterns.to_vec()))
}
+27
View File
@@ -0,0 +1,27 @@
use std::path::{Path, PathBuf};
/// Markers checked in priority order when walking up from a target path.
const MARKERS: &[&str] = &["botignore.toml", ".botignore", ".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.
pub fn find_project_root(target: &Path) -> Option<PathBuf> {
let start = if target.is_file() {
target.parent()?
} else {
target
};
let mut current = Some(start);
while let Some(dir) = current {
for marker in MARKERS {
if dir.join(marker).exists() {
return Some(dir.to_path_buf());
}
}
current = dir.parent();
}
None
}
+47
View File
@@ -0,0 +1,47 @@
use serde::{Deserialize, Serialize};
use std::path::Path;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum TomlConfigError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("toml parse error: {0}")]
Parse(#[from] toml::de::Error),
}
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
pub struct OpRules {
#[serde(default)]
pub patterns: Vec<String>,
}
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
pub struct BashRules {
#[serde(default)]
pub deny: Vec<String>,
#[serde(default)]
pub ask: Vec<String>,
#[serde(default)]
pub allow_prefixes: Vec<String>,
}
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
pub struct BotignoreToml {
pub read: Option<OpRules>,
pub write: Option<OpRules>,
pub bash: Option<BashRules>,
}
impl BotignoreToml {
/// Load `<root>/botignore.toml` if present, else return an empty config.
pub fn load(root: &Path) -> Result<Self, TomlConfigError> {
let path = root.join("botignore.toml");
if !path.exists() {
return Ok(Self::default());
}
let text = std::fs::read_to_string(&path)?;
let cfg = toml::from_str(&text)?;
Ok(cfg)
}
}
+76
View File
@@ -0,0 +1,76 @@
//! Claude Code hook adapter (PreToolUse).
//!
//! Wire format: stdin is one JSON object with `tool_name` and `tool_input`.
//! Stdout is `{"hookSpecificOutput": {...}}` with exit code 0; the JSON
//! carries the verdict.
use super::{AdapterError, HarnessAdapter, PathKind, ToolCall, ToolOp};
use crate::core::Decision;
use serde_json::{json, Value};
use std::path::PathBuf;
pub struct ClaudeAdapter;
impl HarnessAdapter for ClaudeAdapter {
fn name(&self) -> &'static str {
"claude"
}
fn parse_request(&self, input: &[u8]) -> Result<ToolCall, AdapterError> {
let v: Value = serde_json::from_slice(input)?;
let tool_name = v
.get("tool_name")
.and_then(|x| x.as_str())
.ok_or_else(|| AdapterError::Parse("missing tool_name".into()))?
.to_string();
let tool_input = v.get("tool_input").cloned().unwrap_or(Value::Null);
let op = match tool_name.as_str() {
"Read" => path_op(&tool_input, PathKind::Read)?,
"Write" | "Edit" | "MultiEdit" => path_op(&tool_input, PathKind::Write)?,
"Bash" => command_op(&tool_input)?,
other => return Err(AdapterError::UnsupportedTool(other.to_string())),
};
Ok(ToolCall {
tool_name,
op,
raw: v,
})
}
fn render_decision(&self, _call: &ToolCall, decision: &Decision) -> Result<Vec<u8>, AdapterError> {
let (verdict, reason) = match decision {
Decision::Allow => ("allow", String::new()),
Decision::Ask(r) => ("ask", r.message.clone()),
Decision::Deny(r) => ("deny", r.message.clone()),
};
let out = json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": verdict,
"permissionDecisionReason": reason,
}
});
Ok(serde_json::to_vec(&out)?)
}
}
fn path_op(tool_input: &Value, kind: PathKind) -> Result<ToolOp, AdapterError> {
let p = tool_input
.get("file_path")
.and_then(|x| x.as_str())
.ok_or_else(|| AdapterError::Parse("missing tool_input.file_path".into()))?;
Ok(ToolOp::Path {
path: PathBuf::from(p),
kind,
})
}
fn command_op(tool_input: &Value) -> Result<ToolOp, AdapterError> {
let c = tool_input
.get("command")
.and_then(|x| x.as_str())
.ok_or_else(|| AdapterError::Parse("missing tool_input.command".into()))?;
Ok(ToolOp::Command { text: c.to_string() })
}
+67
View File
@@ -0,0 +1,67 @@
//! Harness adapter layer. Normalizes harness-specific payloads into
//! `core` types and renders `Decision` back to harness wire format.
use crate::core::Decision;
use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AdapterError {
#[error("invalid request payload: {0}")]
Parse(String),
#[error("unsupported tool: {0}")]
UnsupportedTool(String),
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("json: {0}")]
Json(#[from] serde_json::Error),
}
/// Normalized tool-call shape consumed by `core::Policy`.
/// Adapters translate harness-specific payloads into this; nothing in
/// `core` knows about adapters.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolCall {
/// Harness's tool name (e.g. "Read", "Write", "Edit", "Bash").
pub tool_name: String,
/// Op classification derived from `tool_name`.
pub op: ToolOp,
/// Original raw payload for the adapter to consult when rendering.
pub raw: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolOp {
Path { path: PathBuf, kind: PathKind },
Command { text: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PathKind {
Read,
Write,
}
/// Trait implemented by each harness adapter. Adapters parse the harness's
/// hook stdin payload into `ToolCall` and render a `Decision` back to the
/// harness's expected stdout format.
pub trait HarnessAdapter {
/// The CLI name (e.g. "claude", "codex", "gemini").
fn name(&self) -> &'static str;
fn parse_request(&self, input: &[u8]) -> Result<ToolCall, AdapterError>;
fn render_decision(&self, call: &ToolCall, decision: &Decision) -> Result<Vec<u8>, AdapterError>;
}
#[cfg(feature = "harness-claude")]
pub mod claude;
/// Look up a registered adapter by CLI name.
pub fn lookup(name: &str) -> Option<Box<dyn HarnessAdapter>> {
match name {
#[cfg(feature = "harness-claude")]
"claude" => Some(Box::new(claude::ClaudeAdapter)),
_ => None,
}
}
+7
View File
@@ -0,0 +1,7 @@
//! `dirigent_fermata` — harness-agnostic policy gate.
//!
//! See `docs/tools/fermata.md` (Dirigent integration plan) and
//! `docs/workpad/brainstorm/fermata.md` (product spec).
pub mod core;
pub mod harness;
+52
View File
@@ -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");
}
+69
View File
@@ -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);
}
+135
View File
@@ -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",
);
}
+39
View File
@@ -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());
}
+42
View File
@@ -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(_)));
}
+52
View File
@@ -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(_)));
}
+64
View File
@@ -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(_)));
}
+69
View File
@@ -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"));
}
+47
View File
@@ -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());
}
+112
View File
@@ -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(_)));
}
+86
View File
@@ -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");
}