sync from monorepo @ 2452e92e

This commit is contained in:
2026-05-08 01:59:04 +02:00
commit b03dc15371
459 changed files with 129586 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
+40
View File
@@ -0,0 +1,40 @@
[package]
name = "dirigent_fermata"
version = "0.1.0"
edition = "2021"
rust-version = "1.75"
description = "Harness-agnostic policy gate for AI coding agents (.botignore + botignore.toml)"
license = "MIT OR Apache-2.0"
repository = "https://git.g4b.org/dirigence/fermata"
readme = "README.md"
keywords = ["ai", "agents", "security", "policy", "gitignore"]
categories = ["command-line-utilities", "development-tools"]
[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 = []
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for describing the origin of the Work and
reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Support. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or support.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Gabor Körber and contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Gabor Körber and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+214
View File
@@ -0,0 +1,214 @@
# 𝄐 dirigent_fermata
**A fast, harness-agnostic policy gate for AI coding agents.**
Drop a `.botignore` file in your project root. Fermata reads it and blocks your agent from reading, writing, or running things it shouldn't — before the tool call happens.
```
.env
.env.*
secrets/**
conf/localsettings.yaml
```
That's all it takes.
---
## Why Fermata
AI coding agents are powerful, but they don't have an innate sense of "don't touch `.env`." Native hook systems in tools like Claude Code let you intercept every file operation — but wiring up your own secure, fast hook for each project is friction. Fermata is that hook, ready to drop in.
- **Fast** — written in Rust; ~15ms per call. Hooks fire on every read, write, and bash operation. Python cold-start (~50150ms) compounds fast. Fermata doesn't.
- **Familiar syntax** — `.botignore` uses gitignore rules via the `ignore` crate (the same engine powering ripgrep).
- **Per-operation control** — `botignore.toml` lets you block writes to `vendor/**` while still allowing reads, or deny specific bash patterns without touching path rules.
- **Harness-agnostic** — plain CLI exit codes work from any shell wrapper; the hook adapter speaks Claude Code's JSON natively.
---
## Status: v0.1
| Component | Status |
|-----------|--------|
| Library (`Op`, `Decision`, `Policy::check`, `Policy::check_command`) | Done |
| `.botignore` walker (project-root walk-up, gitignore semantics) | Done |
| `botignore.toml` parser (read / write / bash namespaces) | Done |
| Path identification heuristics | Done |
| CLI: `fermata check <path>...` | Done |
| CLI: `fermata hook --harness claude` | Done |
| Claude Code PreToolUse adapter | Done |
Out of scope for v0.1: Codex / Gemini hook adapters, MCP server mode, audit log, filesystem watcher.
---
## Install
From source (this monorepo):
```bash
cargo install --path crates/dirigent_fermata --features cli
```
This installs the `fermata` binary into `~/.cargo/bin/`.
---
## Usage
### Checking a path
```bash
fermata check --op read /path/to/.env
# exit 1 — blocked
# stderr: blocked by rule ".env" in /your/project/.botignore
fermata check --op write /path/to/src/main.rs
# exit 0 — allowed
```
### Claude Code hook adapter
```bash
fermata hook --harness claude < hook_payload.json
```
Reads the PreToolUse JSON from stdin, extracts the tool name and path or command, applies policy, and emits the Claude-shaped JSON response. The hook's exit code is always `0`; the verdict is in the JSON body.
---
## Configuration
### `.botignore` — the 80% case
Create a `.botignore` at your project root. Gitignore syntax. Blocks both reads and writes.
```gitignore
# Secrets
.env
.env.*
secrets/**
# Local config overrides
conf/localsettings.yaml
conf/localtestsettings.yaml
# Generated files — let the tools rebuild them, not patch them
dist/**
*.lock
```
Fermata walks up from the target file to find the nearest `.botignore`, so it works correctly even when an agent changes directory.
### `botignore.toml` — per-operation rules
For cases where `.botignore`'s uniform read+write block isn't granular enough:
```toml
[read]
# Block reading secrets outright
patterns = [".env*", "secrets/**", "conf/localsettings.yaml"]
[write]
# Allow reading vendor code but block patching it
patterns = ["vendor/**", "*.lock"]
[bash]
# Hard-block destructive or exfiltrating commands
deny = [
"rm -rf /",
"curl * | sh",
"git push --force*",
]
# Ask before any removal or move
ask = ["rm:*", "mv:*"]
# Narrow allowlist for automated commands
allow_prefixes = ["make test", "git checkout:*"]
```
---
## How it fits into Claude Code
Add fermata as a `PreToolUse` hook in `.claude/settings.json`:
```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Read|Edit|Write",
"hooks": [
{
"type": "command",
"command": "fermata hook --harness claude"
}
]
}
]
}
}
```
When Claude attempts a `Read(.env)`, `Write(vendor/foo.js)`, or `Bash(rm ./secrets/key.pem)`, fermata intercepts the call, checks policy, and returns a deny with a human-readable reason — before any damage is done.
---
## Real-world scenario
A project has `.env`, `conf/localsettings.yaml`, and a `vendor/` tree it doesn't want patched. With `.botignore`:
```gitignore
.env
.env.*
conf/localsettings.yaml
vendor/**
```
Claude attempts to read credentials:
```
Tool: Read
Path: ./conf/localsettings.yaml
Decision: BLOCK — matched rule "conf/localsettings.yaml" (.botignore)
```
Claude attempts to read application code:
```
Tool: Read
Path: ./src/app/main.rs
Decision: ALLOW
```
Claude attempts to run `cat .env` via bash — which would bypass a path-only check:
```toml
# botignore.toml
[bash]
deny = ["cat .env*", "cat conf/localsettings*"]
```
```
Tool: Bash
Command: cat .env
Decision: BLOCK — matched bash deny rule "cat .env*"
```
---
## Architecture
Three concentric layers; nothing inner imports from anything outer:
- **`core/`** — harness-unaware, sync. Types, `.botignore` walker, `botignore.toml` parser, `Policy::check` / `check_command`, path extraction.
- **`harness/`** — `HarnessAdapter` trait over a normalized `ToolCall`. Each adapter lives in its own submodule, feature-gated.
- **`bin/fermata.rs`** — the only place `clap`, stdio, and exit codes appear.
---
## See also
- `docs/tools/fermata.md` — Dirigent integration plan
- `docs/workpad/brainstorm/fermata.md` — full product spec and field notes
- `docs/architecture/crates.md` — crate dependency map
+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,
}
}
@@ -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
}
+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()))
}
@@ -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,
}
}
+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;
@@ -0,0 +1,47 @@
//! Guards that fermata's Cargo.toml carries the metadata required for
//! `cargo publish` and a useful `cargo install`. Reads the manifest as
//! plain text to avoid pulling cargo internals.
use std::fs;
fn manifest() -> String {
fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"))
.expect("read Cargo.toml")
}
#[test]
fn has_license() {
let m = manifest();
assert!(m.contains("license ="), "Cargo.toml missing `license` field");
}
#[test]
fn has_repository() {
let m = manifest();
assert!(m.contains("repository ="), "Cargo.toml missing `repository` field");
}
#[test]
fn has_description() {
let m = manifest();
assert!(m.contains("description ="), "Cargo.toml missing `description`");
}
#[test]
fn has_readme() {
let m = manifest();
assert!(m.contains("readme ="), "Cargo.toml missing `readme` field");
}
#[test]
fn has_keywords_and_categories() {
let m = manifest();
assert!(m.contains("keywords ="), "Cargo.toml missing `keywords`");
assert!(m.contains("categories ="), "Cargo.toml missing `categories`");
}
#[test]
fn has_rust_version() {
let m = manifest();
assert!(m.contains("rust-version ="), "Cargo.toml missing `rust-version` (MSRV)");
}
@@ -0,0 +1,52 @@
use assert_cmd::Command;
use predicates::prelude::*;
use std::fs;
#[test]
fn check_blocks_botignore_match() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join(".botignore"), ".env\n").unwrap();
let target = tmp.path().join(".env");
fs::write(&target, "").unwrap();
Command::cargo_bin("fermata")
.unwrap()
.args(["check", "--op", "read", target.to_str().unwrap()])
.assert()
.failure()
.code(1)
.stdout(predicate::str::contains(".env"));
}
#[test]
fn check_allows_unmatched() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join(".botignore"), ".env\n").unwrap();
let target = tmp.path().join("src.rs");
fs::write(&target, "").unwrap();
Command::cargo_bin("fermata")
.unwrap()
.args(["check", "--op", "read", target.to_str().unwrap()])
.assert()
.success();
}
#[test]
fn check_emits_json_with_flag() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join(".botignore"), ".env\n").unwrap();
let target = tmp.path().join(".env");
fs::write(&target, "").unwrap();
let out = Command::cargo_bin("fermata")
.unwrap()
.args(["check", "--op", "read", "--json", target.to_str().unwrap()])
.assert()
.failure()
.get_output()
.stdout
.clone();
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(v["kind"], "deny");
}
@@ -0,0 +1,69 @@
use assert_cmd::Command;
use std::fs;
#[test]
fn hook_blocks_read_of_botignore_match() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join(".botignore"), ".env\n").unwrap();
let target = tmp.path().join(".env");
fs::write(&target, "").unwrap();
let payload = serde_json::json!({
"tool_name": "Read",
"tool_input": { "file_path": target.to_str().unwrap() }
})
.to_string();
let out = Command::cargo_bin("fermata")
.unwrap()
.args(["hook", "--harness", "claude"])
.write_stdin(payload)
.assert()
.success() // hook always exits 0
.get_output()
.stdout
.clone();
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
assert!(v["hookSpecificOutput"]["permissionDecisionReason"]
.as_str()
.unwrap()
.contains(".env"));
}
#[test]
fn hook_allows_unrelated_read() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join(".botignore"), ".env\n").unwrap();
let target = tmp.path().join("src.rs");
fs::write(&target, "").unwrap();
let payload = serde_json::json!({
"tool_name": "Read",
"tool_input": { "file_path": target.to_str().unwrap() }
})
.to_string();
let out = Command::cargo_bin("fermata")
.unwrap()
.args(["hook", "--harness", "claude"])
.write_stdin(payload)
.assert()
.success()
.get_output()
.stdout
.clone();
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "allow");
}
#[test]
fn hook_unknown_harness_errors() {
Command::cargo_bin("fermata")
.unwrap()
.args(["hook", "--harness", "doesnotexist"])
.write_stdin("{}")
.assert()
.code(2);
}
@@ -0,0 +1,135 @@
use dirigent_fermata::core::botignore::BotignoreSet;
use std::fs;
use tempfile::TempDir;
#[test]
fn matches_simple_pattern() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(root.join(".botignore"), ".env\nsecrets/\n").unwrap();
let set = BotignoreSet::load(root).unwrap();
let env = root.join(".env");
fs::write(&env, "").unwrap();
let m = set.matched(&env).unwrap();
assert!(m.is_some(), ".env should be matched");
assert_eq!(m.unwrap().pattern, ".env");
}
#[test]
fn does_not_match_unrelated_files() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(root.join(".botignore"), ".env\n").unwrap();
let set = BotignoreSet::load(root).unwrap();
let other = root.join("README.md");
fs::write(&other, "").unwrap();
assert!(set.matched(&other).unwrap().is_none());
}
#[test]
fn negation_pattern_excludes() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(root.join(".botignore"), "*.log\n!keep.log\n").unwrap();
let set = BotignoreSet::load(root).unwrap();
let blocked = root.join("foo.log");
fs::write(&blocked, "").unwrap();
assert!(set.matched(&blocked).unwrap().is_some());
let allowed = root.join("keep.log");
fs::write(&allowed, "").unwrap();
assert!(set.matched(&allowed).unwrap().is_none());
}
#[test]
fn empty_or_missing_botignore_is_ok() {
let tmp = TempDir::new().unwrap();
let set = BotignoreSet::load(tmp.path()).unwrap();
let any = tmp.path().join("anything.txt");
std::fs::write(&any, "").unwrap();
assert!(set.matched(&any).unwrap().is_none());
}
#[test]
fn nested_botignore_is_scoped_to_its_directory() {
// A `.botignore` in a subdirectory only applies under that subdirectory,
// matching gitignore semantics: a sibling file with the same name at the
// root is NOT affected.
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join("frontend")).unwrap();
fs::write(root.join("frontend/.botignore"), "secret.key\n").unwrap();
let set = BotignoreSet::load(root).unwrap();
let blocked = root.join("frontend/secret.key");
fs::write(&blocked, "").unwrap();
let m = set
.matched(&blocked)
.unwrap()
.expect("frontend/secret.key should match");
let src = m.source.to_string_lossy().replace('\\', "/");
assert!(
src.ends_with("frontend/.botignore"),
"Rule.source should point at the nested file; was {}",
src,
);
let unblocked = root.join("secret.key");
fs::write(&unblocked, "").unwrap();
assert!(
set.matched(&unblocked).unwrap().is_none(),
"top-level secret.key should NOT be matched (rule scoped to frontend/)",
);
}
#[test]
fn nested_botignore_anchored_pattern_is_local() {
// A leading `/` anchors the pattern to the directory of the .botignore
// file it's declared in.
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join("frontend")).unwrap();
fs::write(root.join("frontend/.botignore"), "/secret.key\n").unwrap();
let set = BotignoreSet::load(root).unwrap();
let blocked = root.join("frontend/secret.key");
fs::write(&blocked, "").unwrap();
assert!(set.matched(&blocked).unwrap().is_some());
let unblocked = root.join("secret.key");
fs::write(&unblocked, "").unwrap();
assert!(
set.matched(&unblocked).unwrap().is_none(),
"anchored /secret.key should NOT match outside frontend/",
);
}
#[test]
fn nested_botignore_overrides_root() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(root.join(".botignore"), "*.log\n").unwrap();
fs::create_dir_all(root.join("logs")).unwrap();
fs::write(root.join("logs/.botignore"), "!keep.log\n").unwrap();
let set = BotignoreSet::load(root).unwrap();
let blocked = root.join("logs/foo.log");
fs::write(&blocked, "").unwrap();
assert!(set.matched(&blocked).unwrap().is_some());
let kept = root.join("logs/keep.log");
fs::write(&kept, "").unwrap();
assert!(
set.matched(&kept).unwrap().is_none(),
"logs/.botignore should un-ignore keep.log",
);
}
@@ -0,0 +1,39 @@
use dirigent_fermata::core::extract::{extract_path_candidates, Confidence};
#[test]
fn extracts_absolute_unix_path() {
let s = "the file is at /home/user/.env and was modified";
let cs = extract_path_candidates(s);
assert!(cs.iter().any(|c| c.text == "/home/user/.env" && c.confidence == Confidence::High));
}
#[test]
fn extracts_absolute_windows_path() {
let s = r"see C:\Users\me\secret.toml for details";
let cs = extract_path_candidates(s);
assert!(cs.iter().any(|c| c.text == r"C:\Users\me\secret.toml" && c.confidence == Confidence::High));
}
#[test]
fn extracts_relative_with_separator() {
let s = "modified src/lib.rs and tests/foo.rs";
let cs = extract_path_candidates(s);
let texts: Vec<_> = cs.iter().map(|c| c.text.as_str()).collect();
assert!(texts.contains(&"src/lib.rs"));
assert!(texts.contains(&"tests/foo.rs"));
}
#[test]
fn bare_filename_with_extension_is_low_confidence() {
let s = "open README.md please";
let cs = extract_path_candidates(s);
let r = cs.iter().find(|c| c.text == "README.md").unwrap();
assert_eq!(r.confidence, Confidence::Low);
}
#[test]
fn ignores_pure_words() {
let s = "the quick brown fox";
let cs = extract_path_candidates(s);
assert!(cs.is_empty());
}
@@ -0,0 +1,42 @@
use dirigent_fermata::core::{Decision, Op, Reason, Rule};
#[test]
fn op_variants_exist() {
let _ = Op::Read;
let _ = Op::Write;
let _ = Op::Execute;
}
#[test]
fn decision_allow_is_simple() {
let d = Decision::Allow;
assert!(matches!(d, Decision::Allow));
}
#[test]
fn decision_deny_carries_reason() {
let rule = Rule {
source: "/proj/.botignore".into(),
pattern: ".env".into(),
};
let d = Decision::Deny(Reason {
message: "blocked by .botignore".into(),
rule: Some(rule),
});
match d {
Decision::Deny(r) => {
assert_eq!(r.message, "blocked by .botignore");
assert!(r.rule.is_some());
}
_ => panic!("expected Deny"),
}
}
#[test]
fn decision_ask_carries_reason() {
let d = Decision::Ask(Reason {
message: "needs confirmation".into(),
rule: None,
});
assert!(matches!(d, Decision::Ask(_)));
}
@@ -0,0 +1,52 @@
use dirigent_fermata::core::{Decision, Policy};
use std::fs;
use tempfile::TempDir;
fn project_with(toml: &str) -> TempDir {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("botignore.toml"), toml).unwrap();
tmp
}
#[test]
fn deny_substring_blocks() {
let tmp = project_with("[bash]\ndeny = [\"rm -rf /\"]\n");
let p = Policy::load(tmp.path()).unwrap();
assert!(matches!(p.check_command("sudo rm -rf / now").unwrap(), Decision::Deny(_)));
}
#[test]
fn deny_glob_blocks() {
let tmp = project_with("[bash]\ndeny = [\"git push --force*\"]\n");
let p = Policy::load(tmp.path()).unwrap();
assert!(matches!(p.check_command("git push --force-with-lease").unwrap(), Decision::Deny(_)));
}
#[test]
fn ask_returns_ask() {
let tmp = project_with("[bash]\nask = [\"rm *\"]\n");
let p = Policy::load(tmp.path()).unwrap();
assert!(matches!(p.check_command("rm somefile").unwrap(), Decision::Ask(_)));
}
#[test]
fn allow_prefixes_allows() {
let tmp = project_with("[bash]\nallow_prefixes = [\"make test\"]\n");
let p = Policy::load(tmp.path()).unwrap();
assert_eq!(p.check_command("make test").unwrap(), Decision::Allow);
assert_eq!(p.check_command("make test-unit").unwrap(), Decision::Allow);
}
#[test]
fn no_rules_means_allow() {
let tmp = project_with("");
let p = Policy::load(tmp.path()).unwrap();
assert_eq!(p.check_command("anything goes").unwrap(), Decision::Allow);
}
#[test]
fn deny_takes_precedence_over_allow_prefix() {
let tmp = project_with("[bash]\ndeny = [\"rm -rf /\"]\nallow_prefixes = [\"rm\"]\n");
let p = Policy::load(tmp.path()).unwrap();
assert!(matches!(p.check_command("rm -rf /").unwrap(), Decision::Deny(_)));
}
@@ -0,0 +1,64 @@
use dirigent_fermata::core::{Decision, Op, Policy};
use std::fs;
use tempfile::TempDir;
fn make_project(botignore: &str, toml_text: &str) -> TempDir {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join(".botignore"), botignore).unwrap();
if !toml_text.is_empty() {
fs::write(tmp.path().join("botignore.toml"), toml_text).unwrap();
}
tmp
}
#[test]
fn botignore_blocks_read() {
let tmp = make_project(".env\n", "");
let policy = Policy::load(tmp.path()).unwrap();
let target = tmp.path().join(".env");
fs::write(&target, "").unwrap();
let d = policy.check(Op::Read, &target).unwrap();
assert!(matches!(d, Decision::Deny(_)));
}
#[test]
fn botignore_blocks_write_too() {
let tmp = make_project(".env\n", "");
let policy = Policy::load(tmp.path()).unwrap();
let target = tmp.path().join(".env");
let d = policy.check(Op::Write, &target).unwrap();
assert!(matches!(d, Decision::Deny(_)));
}
#[test]
fn unmatched_path_allowed() {
let tmp = make_project(".env\n", "");
let policy = Policy::load(tmp.path()).unwrap();
let target = tmp.path().join("src/main.rs");
fs::create_dir_all(target.parent().unwrap()).unwrap();
fs::write(&target, "").unwrap();
let d = policy.check(Op::Read, &target).unwrap();
assert_eq!(d, Decision::Allow);
}
#[test]
fn toml_read_block_applies_only_to_read() {
let tmp = make_project("", "[read]\npatterns = [\"secrets/**\"]\n");
let policy = Policy::load(tmp.path()).unwrap();
let target = tmp.path().join("secrets/key.pem");
fs::create_dir_all(target.parent().unwrap()).unwrap();
fs::write(&target, "").unwrap();
assert!(matches!(policy.check(Op::Read, &target).unwrap(), Decision::Deny(_)));
assert_eq!(policy.check(Op::Write, &target).unwrap(), Decision::Allow);
}
#[test]
fn toml_write_block_applies_only_to_write() {
let tmp = make_project("", "[write]\npatterns = [\"vendor/**\"]\n");
let policy = Policy::load(tmp.path()).unwrap();
let target = tmp.path().join("vendor/lib.rs");
fs::create_dir_all(target.parent().unwrap()).unwrap();
fs::write(&target, "").unwrap();
assert_eq!(policy.check(Op::Read, &target).unwrap(), Decision::Allow);
assert!(matches!(policy.check(Op::Write, &target).unwrap(), Decision::Deny(_)));
}
@@ -0,0 +1,69 @@
use dirigent_fermata::core::project::find_project_root;
use std::fs;
use tempfile::TempDir;
#[test]
fn finds_botignore_toml_first() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join("sub/deep")).unwrap();
fs::write(root.join("botignore.toml"), "").unwrap();
fs::write(root.join(".botignore"), "").unwrap();
fs::create_dir_all(root.join(".git")).unwrap();
let target = root.join("sub/deep/file.rs");
fs::write(&target, "").unwrap();
let found = find_project_root(&target).unwrap();
assert_eq!(found, root);
}
#[test]
fn falls_back_to_botignore() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join("sub")).unwrap();
fs::write(root.join(".botignore"), "").unwrap();
let target = root.join("sub/file.rs");
fs::write(&target, "").unwrap();
let found = find_project_root(&target).unwrap();
assert_eq!(found, root);
}
#[test]
fn falls_back_to_git() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join("sub")).unwrap();
fs::create_dir_all(root.join(".git")).unwrap();
let target = root.join("sub/file.rs");
fs::write(&target, "").unwrap();
let found = find_project_root(&target).unwrap();
assert_eq!(found, root);
}
#[test]
fn returns_none_when_no_marker() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("file.rs");
std::fs::write(&target, "").unwrap();
assert!(find_project_root(&target).is_none());
}
#[test]
fn walks_up_from_file_path_not_cwd() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join("a/b/c")).unwrap();
fs::write(root.join("a/.botignore"), "").unwrap();
let target = root.join("a/b/c/file.rs");
fs::write(&target, "").unwrap();
let found = find_project_root(&target).unwrap();
assert_eq!(found, root.join("a"));
}
@@ -0,0 +1,47 @@
use dirigent_fermata::core::toml_config::{BotignoreToml, OpRules, BashRules};
#[test]
fn parses_full_config() {
let src = r#"
[read]
patterns = [".env*", "secrets/**"]
[write]
patterns = ["vendor/**", "*.lock"]
[bash]
deny = ["rm -rf /", "git push --force*"]
ask = ["rm:*"]
allow_prefixes = ["make test", "git checkout:*"]
"#;
let cfg: BotignoreToml = toml::from_str(src).unwrap();
assert_eq!(cfg.read.unwrap().patterns, vec![".env*", "secrets/**"]);
assert_eq!(cfg.write.unwrap().patterns, vec!["vendor/**", "*.lock"]);
let bash = cfg.bash.unwrap();
assert_eq!(bash.deny, vec!["rm -rf /", "git push --force*"]);
assert_eq!(bash.ask, vec!["rm:*"]);
assert_eq!(bash.allow_prefixes, vec!["make test", "git checkout:*"]);
}
#[test]
fn empty_config_is_valid() {
let cfg: BotignoreToml = toml::from_str("").unwrap();
assert!(cfg.read.is_none());
assert!(cfg.write.is_none());
assert!(cfg.bash.is_none());
}
#[test]
fn loads_from_disk_when_present() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("botignore.toml"), "[read]\npatterns = [\".env\"]\n").unwrap();
let cfg = BotignoreToml::load(tmp.path()).unwrap();
assert_eq!(cfg.read.unwrap().patterns, vec![".env"]);
}
#[test]
fn loads_empty_when_missing() {
let tmp = tempfile::tempdir().unwrap();
let cfg = BotignoreToml::load(tmp.path()).unwrap();
assert!(cfg.read.is_none());
}
@@ -0,0 +1,112 @@
//! Smoke-test contract from `docs/workpad/brainstorm/fermata.md` Appendix A.4.
use dirigent_fermata::core::{Decision, Op, Policy};
use std::fs;
use tempfile::TempDir;
fn fixture() -> TempDir {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(root.join(".botignore"), ".env\n.env.*\nconf/cert/**\nconf/mitmproxy/**\n").unwrap();
fs::write(
root.join("botignore.toml"),
r#"
[read]
patterns = [
"conf/localtestsettings.yaml",
"conf/localsettings.yaml",
"conf/default-secrets.yaml",
".claude/self-reflections/**",
]
[write]
patterns = [
"conf/localtestsettings.yaml",
"conf/localsettings.yaml",
"conf/default-secrets.yaml",
]
[bash]
deny = ["localtestsettings.yaml", "localsettings.yaml", "default-secrets.yaml", ".env"]
ask = ["rm *", "mv *"]
allow_prefixes = ["make test"]
"#,
)
.unwrap();
fs::create_dir_all(root.join("conf")).unwrap();
fs::create_dir_all(root.join("datatap")).unwrap();
fs::create_dir_all(root.join(".claude/self-reflections")).unwrap();
for f in [".env", "conf/localsettings.yaml", "datatap/foo.py", ".claude/self-reflections/x.md"] {
fs::write(root.join(f), "").unwrap();
}
tmp
}
#[test]
fn read_dot_env_denied() {
let t = fixture();
let p = Policy::load(t.path()).unwrap();
assert!(matches!(p.check(Op::Read, &t.path().join(".env")).unwrap(), Decision::Deny(_)));
}
#[test]
fn bash_cat_dot_env_denied() {
let t = fixture();
let p = Policy::load(t.path()).unwrap();
assert!(matches!(p.check_command("cat ./.env").unwrap(), Decision::Deny(_)));
}
#[test]
fn bash_rm_localsettings_denied() {
let t = fixture();
let p = Policy::load(t.path()).unwrap();
assert!(matches!(
p.check_command("rm ./conf/localsettings.yaml").unwrap(),
Decision::Deny(_)
));
}
#[test]
fn write_localsettings_denied() {
let t = fixture();
let p = Policy::load(t.path()).unwrap();
assert!(matches!(
p.check(Op::Write, &t.path().join("conf/localsettings.yaml")).unwrap(),
Decision::Deny(_)
));
}
#[test]
fn edit_datatap_foo_py_allowed() {
let t = fixture();
let p = Policy::load(t.path()).unwrap();
assert_eq!(
p.check(Op::Write, &t.path().join("datatap/foo.py")).unwrap(),
Decision::Allow
);
}
#[test]
fn bash_make_test_allowed() {
let t = fixture();
let p = Policy::load(t.path()).unwrap();
assert_eq!(p.check_command("make test").unwrap(), Decision::Allow);
}
#[test]
fn bash_rm_somefile_asks() {
let t = fixture();
let p = Policy::load(t.path()).unwrap();
assert!(matches!(p.check_command("rm somefile").unwrap(), Decision::Ask(_)));
}
#[test]
fn read_self_reflections_asks() {
// Note: A.4 has self-reflections under "ask" — current toml schema uses `[read].patterns`
// for hard reads. This documents the gap; once toml has a `[read].ask`, switch to Ask.
let t = fixture();
let p = Policy::load(t.path()).unwrap();
let d = p.check(Op::Read, &t.path().join(".claude/self-reflections/x.md")).unwrap();
assert!(matches!(d, Decision::Deny(_)));
}
@@ -0,0 +1,86 @@
use dirigent_fermata::core::{Decision, Reason};
use dirigent_fermata::harness::{HarnessAdapter, PathKind, ToolOp};
use dirigent_fermata::harness::claude::ClaudeAdapter;
#[test]
fn parses_read_payload() {
let payload = br#"{"tool_name":"Read","tool_input":{"file_path":"/proj/.env"}}"#;
let call = ClaudeAdapter.parse_request(payload).unwrap();
assert_eq!(call.tool_name, "Read");
match call.op {
ToolOp::Path { path, kind } => {
assert_eq!(path.to_string_lossy(), "/proj/.env");
assert_eq!(kind, PathKind::Read);
}
_ => panic!("expected Path op"),
}
}
#[test]
fn parses_write_payload() {
let payload = br#"{"tool_name":"Write","tool_input":{"file_path":"/proj/out.txt"}}"#;
let call = ClaudeAdapter.parse_request(payload).unwrap();
assert!(matches!(call.op, ToolOp::Path { kind: PathKind::Write, .. }));
}
#[test]
fn parses_edit_as_write() {
let payload = br#"{"tool_name":"Edit","tool_input":{"file_path":"/proj/src.rs"}}"#;
let call = ClaudeAdapter.parse_request(payload).unwrap();
assert!(matches!(call.op, ToolOp::Path { kind: PathKind::Write, .. }));
}
#[test]
fn parses_multiedit_as_write() {
let payload = br#"{"tool_name":"MultiEdit","tool_input":{"file_path":"/proj/src.rs","edits":[]}}"#;
let call = ClaudeAdapter.parse_request(payload).unwrap();
assert!(matches!(call.op, ToolOp::Path { kind: PathKind::Write, .. }));
}
#[test]
fn parses_bash_payload() {
let payload = br#"{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}"#;
let call = ClaudeAdapter.parse_request(payload).unwrap();
match call.op {
ToolOp::Command { text } => assert_eq!(text, "rm -rf /"),
_ => panic!("expected Command op"),
}
}
#[test]
fn renders_deny_as_hookspecificoutput() {
let payload = br#"{"tool_name":"Read","tool_input":{"file_path":"/proj/.env"}}"#;
let call = ClaudeAdapter.parse_request(payload).unwrap();
let d = Decision::Deny(Reason {
message: "blocked by .botignore: .env".into(),
rule: None,
});
let out = ClaudeAdapter.render_decision(&call, &d).unwrap();
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(v["hookSpecificOutput"]["hookEventName"], "PreToolUse");
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
assert!(v["hookSpecificOutput"]["permissionDecisionReason"]
.as_str()
.unwrap()
.contains(".env"));
}
#[test]
fn renders_allow_as_allow() {
let payload = br#"{"tool_name":"Read","tool_input":{"file_path":"/proj/src/main.rs"}}"#;
let call = ClaudeAdapter.parse_request(payload).unwrap();
let out = ClaudeAdapter.render_decision(&call, &Decision::Allow).unwrap();
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "allow");
}
#[test]
fn renders_ask_as_ask() {
let payload = br#"{"tool_name":"Bash","tool_input":{"command":"rm something"}}"#;
let call = ClaudeAdapter.parse_request(payload).unwrap();
let out = ClaudeAdapter
.render_decision(&call, &Decision::Ask(Reason { message: "confirm".into(), rule: None }))
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "ask");
}