commit 97001e1544b0726e9f8d73c2b34e476afe4137a2 Author: Gabor Körber Date: Thu Apr 30 21:58:57 2026 +0200 chore: rename packages/ to crates/ Move all 29 workspace members from packages// to crates//. 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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..49ffa67 --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9b5ce59 --- /dev/null +++ b/Cargo.toml @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..cdc31e2 --- /dev/null +++ b/README.md @@ -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 ...`, `fermata hook --harness `. +- 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 diff --git a/src/bin/fermata.rs b/src/bin/fermata.rs new file mode 100644 index 0000000..23dbcb5 --- /dev/null +++ b/src/bin/fermata.rs @@ -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, + }, + /// 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 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 = 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, 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, + } +} diff --git a/src/core/botignore.rs b/src/core/botignore.rs new file mode 100644 index 0000000..4c9ac6c --- /dev/null +++ b/src/core/botignore.rs @@ -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, +} + +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 { + 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, BotignoreError> { + let is_dir = path.is_dir(); + let mut current: Option<&ScopedMatcher> = None; + let mut current_pattern: Option = 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(), + })) + } +} diff --git a/src/core/decision.rs b/src/core/decision.rs new file mode 100644 index 0000000..cd7dae5 --- /dev/null +++ b/src/core/decision.rs @@ -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, +} + +#[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(_)) + } +} diff --git a/src/core/extract.rs b/src/core/extract.rs new file mode 100644 index 0000000..6535513 --- /dev/null +++ b/src/core/extract.rs @@ -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 { + static UNIX_ABS: OnceLock = OnceLock::new(); + static WIN_ABS: OnceLock = OnceLock::new(); + static REL_WITH_SEP: OnceLock = OnceLock::new(); + static BARE_NAME: OnceLock = 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 +} diff --git a/src/core/mod.rs b/src/core/mod.rs new file mode 100644 index 0000000..519bf69 --- /dev/null +++ b/src/core/mod.rs @@ -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; diff --git a/src/core/op.rs b/src/core/op.rs new file mode 100644 index 0000000..99e5ee8 --- /dev/null +++ b/src/core/op.rs @@ -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, +} diff --git a/src/core/policy.rs b/src/core/policy.rs new file mode 100644 index 0000000..5479c30 --- /dev/null +++ b/src/core/policy.rs @@ -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, + write_patterns: Vec, +} + +impl Policy { + pub fn load(root: &Path) -> Result { + 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 { + 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 { + // 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, 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), 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())) +} diff --git a/src/core/project.rs b/src/core/project.rs new file mode 100644 index 0000000..bceba07 --- /dev/null +++ b/src/core/project.rs @@ -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 { + 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 +} diff --git a/src/core/toml_config.rs b/src/core/toml_config.rs new file mode 100644 index 0000000..8f2997f --- /dev/null +++ b/src/core/toml_config.rs @@ -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, +} + +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +pub struct BashRules { + #[serde(default)] + pub deny: Vec, + #[serde(default)] + pub ask: Vec, + #[serde(default)] + pub allow_prefixes: Vec, +} + +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +pub struct BotignoreToml { + pub read: Option, + pub write: Option, + pub bash: Option, +} + +impl BotignoreToml { + /// Load `/botignore.toml` if present, else return an empty config. + pub fn load(root: &Path) -> Result { + 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) + } +} diff --git a/src/harness/claude.rs b/src/harness/claude.rs new file mode 100644 index 0000000..e0c3576 --- /dev/null +++ b/src/harness/claude.rs @@ -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 { + 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, 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 { + 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 { + 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() }) +} diff --git a/src/harness/mod.rs b/src/harness/mod.rs new file mode 100644 index 0000000..347b7b3 --- /dev/null +++ b/src/harness/mod.rs @@ -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; + + fn render_decision(&self, call: &ToolCall, decision: &Decision) -> Result, AdapterError>; +} + +#[cfg(feature = "harness-claude")] +pub mod claude; + +/// Look up a registered adapter by CLI name. +pub fn lookup(name: &str) -> Option> { + match name { + #[cfg(feature = "harness-claude")] + "claude" => Some(Box::new(claude::ClaudeAdapter)), + _ => None, + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..4bb9af6 --- /dev/null +++ b/src/lib.rs @@ -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; diff --git a/tests/cli_check.rs b/tests/cli_check.rs new file mode 100644 index 0000000..d909902 --- /dev/null +++ b/tests/cli_check.rs @@ -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"); +} diff --git a/tests/cli_hook_claude.rs b/tests/cli_hook_claude.rs new file mode 100644 index 0000000..40fe591 --- /dev/null +++ b/tests/cli_hook_claude.rs @@ -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); +} diff --git a/tests/core_botignore.rs b/tests/core_botignore.rs new file mode 100644 index 0000000..b54ae0f --- /dev/null +++ b/tests/core_botignore.rs @@ -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", + ); +} diff --git a/tests/core_extract.rs b/tests/core_extract.rs new file mode 100644 index 0000000..72a07d9 --- /dev/null +++ b/tests/core_extract.rs @@ -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()); +} diff --git a/tests/core_op_decision.rs b/tests/core_op_decision.rs new file mode 100644 index 0000000..ee35d7a --- /dev/null +++ b/tests/core_op_decision.rs @@ -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(_))); +} diff --git a/tests/core_policy_command.rs b/tests/core_policy_command.rs new file mode 100644 index 0000000..6d49110 --- /dev/null +++ b/tests/core_policy_command.rs @@ -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(_))); +} diff --git a/tests/core_policy_path.rs b/tests/core_policy_path.rs new file mode 100644 index 0000000..4612079 --- /dev/null +++ b/tests/core_policy_path.rs @@ -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(_))); +} diff --git a/tests/core_project.rs b/tests/core_project.rs new file mode 100644 index 0000000..b4a3dc3 --- /dev/null +++ b/tests/core_project.rs @@ -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")); +} diff --git a/tests/core_toml_config.rs b/tests/core_toml_config.rs new file mode 100644 index 0000000..1ed9e36 --- /dev/null +++ b/tests/core_toml_config.rs @@ -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()); +} diff --git a/tests/fixtures_a4.rs b/tests/fixtures_a4.rs new file mode 100644 index 0000000..eea6639 --- /dev/null +++ b/tests/fixtures_a4.rs @@ -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(_))); +} diff --git a/tests/harness_claude.rs b/tests/harness_claude.rs new file mode 100644 index 0000000..2521973 --- /dev/null +++ b/tests/harness_claude.rs @@ -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"); +}