chore: rename packages/ to crates/
Move all 29 workspace members from packages/<name>/ to crates/<name>/. Updates: workspace Cargo.toml (members + path deps), justfile, root CLAUDE.md, scripts/build/CARGO_INSTALL.md, docs/architecture/crates.md (renamed from packages.md), structural references in docs/architecture and docs/configuration, per-crate CLAUDE.md self-references. Historical plans, reports, and building/ docs are left untouched. No behavior change; just check-all stays green and fermata tests pass.
This commit is contained in:
@@ -0,0 +1,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,
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -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(_))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() })
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user