Files
dirigent/crates/dirigent_fermata
2026-05-08 01:59:04 +02:00
..
2026-05-08 01:59:04 +02:00
2026-05-08 01:59:04 +02:00
2026-05-08 01:59:04 +02:00
2026-05-08 01:59:04 +02:00
2026-05-08 01:59:04 +02:00
2026-05-08 01:59:04 +02:00
2026-05-08 01:59:04 +02:00

𝄐 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 controlbotignore.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):

cargo install --path crates/dirigent_fermata --features cli

This installs the fermata binary into ~/.cargo/bin/.


Usage

Checking a path

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

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.

# 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:

[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:

{
  "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:

.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:

# 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