Files
dirigent/crates/dirigent_fermata/README.md
T
2026-05-09 00:05:40 +02:00

215 lines
5.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 𝄐 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/settings.local.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/settings.local.yaml
conf/settings.test.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/settings.local.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/settings.local.yaml`, and a `vendor/` tree it doesn't want patched. With `.botignore`:
```gitignore
.env
.env.*
conf/settings.local.yaml
vendor/**
```
Claude attempts to read credentials:
```
Tool: Read
Path: ./conf/settings.local.yaml
Decision: BLOCK — matched rule "conf/settings.local.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/settings.local*"]
```
```
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