diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 46faab1..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,43 +0,0 @@ -# Package: dirigent_fermata - -Harness-agnostic secret redaction engine and 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), `aho-corasick`, `serde_yaml` -- **Status**: v0.2 — secret redaction + policy gate (`.botsecrets` now includes `[policy]` section) - -## Layering - -Three concentric layers; nothing inner imports from anything outer. - -- **`core/`** — harness-unaware, transport-unaware, sync. Types (`Op`, `Decision`), `.botignore` walker, `Policy::check` / `check_command`, path extraction. `.botsecrets` contains a `[policy]` section for access control rules (`fermata.toml` is accepted as an alias for `.botsecrets`). Sync, no tokio. - - **`core/secrets/`** — the secret filtering engine: - - `config.rs` — `.botsecrets` TOML parser (`SecretsConfig`, `PolicyConfig`) and hierarchical resolution (user, project, local override). - - `manifest.rs` — discovers secret-containing files from `.botsecrets` patterns and loads their content for redaction. - - `parser.rs` — multi-format secret file parser (`.env`, TOML, YAML, JSON). Extracts key-value pairs where the value is a secret. - - `patterns.rs` — built-in key name patterns (~30 universal patterns like `*_KEY`, `*_SECRET`, `*_PASSWORD`) and gitleaks-derived regex patterns for heuristic scanning. - - `redactor.rs` — `Redactor` builds an Aho-Corasick automaton from known secret values and replaces them in arbitrary text. Sub-millisecond performance. - - `scanner.rs` — `Scanner` applies heuristic regex patterns to detect secrets not covered by the known-value manifest (entropy-based and format-based detection). -- **`harness/`** — `HarnessAdapter` trait over a normalized `ToolCall` (PreToolUse) and `PostToolUsePayload` (PostToolUse). Each adapter (Claude, future Codex, etc.) lives in its own submodule, feature-gated. PostToolUse enables output redaction via `updatedToolOutput` before content enters the LLM context. -- **`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.2) - -Codex / Gemini hook adapters, MCP server mode, `readonly_only` Bash mode, audit log, filesystem watcher, context taint tracking. 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 -- `docs/architecture/fermata-security-philosophy.md` — security philosophy and the reveal triangle -- `.botsecrets` format: `core/secrets/config.rs` — unified config for secret redaction and policy (`fermata.toml` accepted as alias) diff --git a/Cargo.toml b/Cargo.toml index a6029fa..583e0e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,45 +1,42 @@ -[package] -name = "dirigent_fermata" -version = "0.1.0" -edition = "2021" -rust-version = "1.75" -description = "Harness-agnostic policy gate for AI coding agents (.botignore + .botsecrets)" -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] -aho-corasick = "1.1" -globset = "0.4" -ignore = "0.4" -walkdir = "2" -toml = "0.8" -regex = "1.10" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -serde_yaml = "0.9" -thiserror = "2.0" -clap = { version = "4.5", features = ["derive"], optional = true } - -[dev-dependencies] -tempfile = "3.10" -assert_cmd = "2.0" -predicates = "3.1" - -[features] -default = ["cli", "harness-claude"] -cli = ["dep:clap"] -harness-claude = [] - -[lints] -workspace = true +[package] +name = "dirigent_fermata" +version = "0.1.0" +edition = "2021" +rust-version = "1.75" +description = "Harness-agnostic policy gate for AI coding agents (.botignore + .botsecrets)" +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] +aho-corasick = "1.1" +globset = "0.4" +ignore = "0.4" +walkdir = "2" +toml = "0.8" +regex = "1.10" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_yaml = "0.9" +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 = [] diff --git a/README.md b/README.md index 65ee938..0110946 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,60 @@ -# fermata +# 𝄐 fermata -**A fast, harness-agnostic security layer for AI coding agents.** +**The security layer for AI coding agents.** -AI coding agents read files, run commands, and inspect output as part of their normal workflow. When they read `.env`, secret values get tokenized into the LLM's context window -- and from there they can leak into commits, PR descriptions, log messages, or API calls. The solution is not blocking the read -- the agent needs to see config structure and key names to reason about your project. The solution is **redacting secret values from the output before they reach the model**. No AI coding agent ships built-in post-read secret filtering today. fermata fixes that. +AI coding agents read files, run shell commands, and inspect output as part of normal work. When they read `.env`, the secret values get tokenized into the LLM's context window. From there, they can leak into commits, PR descriptions, or API calls the agent makes. The secret is irrecoverably revealed. -## Why +fermata sits between the agent and its tools. It blocks operations that shouldn't happen, and scrubs secret values from the output of operations that should. -Blocking reads is the wrong approach. The agent needs to see file structure. It needs to know which keys exist in `.env`, what your database config looks like, how your secrets are organized. What it does *not* need to see is the actual secret values. An agent can have full read access to `.env` without secret values being revealed -- if the output is redacted before it reaches the model. +> [!CAUTION] +> **Alpha software.** Fermata is functional and in daily use by the author, but not widely tested across diverse environments. The core library and Claude Code hook adapters are production-grade; other features are earlier in maturity. Expect rough edges and breaking changes. -fermata operates on two independent levels: +--- -- **Secret filtering** (PostToolUse) -- `.botsecrets` declares where secrets live; fermata parses them, builds an Aho-Corasick automaton, and redacts secret *values* from tool output before they enter the LLM context. This is the primary defense. It catches secrets regardless of how they appear -- direct reads, shell output, log files, error messages. -- **Policy gate** (PreToolUse) -- `.botsecrets [policy]` / `.botignore` blocks dangerous writes and destructive commands before they execute. Supplementary protection for write safety and anti-jailbreak. +## The Problem -The key insight: file-level access control operates on file identity (*which file*). Secret redaction operates on data content (*which values*). The reveal problem can only be solved at the data-content level. +Traditional security blocks the file. But secrets also appear in shell output, log files, error messages, environment variable dumps, and indirect reads that bypass any access-control list. -> **Note:** fermata also accepts `fermata.toml` as an alias for `.botsecrets` (same format, `.botsecrets` takes priority when both exist). +

+ Where secrets leak from — blocking the file is necessary but not sufficient +

+ +The actual concern is not "can the agent open this file?" but "do secret *values* enter the LLM context?" An agent can have read access to `.env` without the secret values being revealed — if the output is redacted before it reaches the model. + +--- + +## How It Works + +fermata interposes on the tool lifecycle at two points: + +

+ How fermata intercepts — PreToolUse blocks, PostToolUse redacts +

+ +**PreToolUse** — Before the tool executes, fermata checks `.botsecrets [policy]` and `.botignore` rules against the operation. A blocked write never happens. A blocked command never runs. Most harnesses already handle basic file blocking, but fermata catches stragglers and works in permissive/yolo modes too. + +**PostToolUse** — After the tool executes, fermata scans the output for secret values. Declared secrets (loaded from files matched by `.botsecrets`) are replaced using an Aho-Corasick automaton — zero false negatives, sub-millisecond. A secondary heuristic scan catches undeclared secrets that match known formats (AWS keys, JWTs, GitHub PATs, database URLs). This is the primary defense layer. + +This means `source .env && echo $DB_PASSWORD` is caught even though no file read was blocked — the secret value itself is scrubbed from the output before the LLM ever sees it. + +--- ## Quick Start ### Install ```bash -cargo install --path . --features cli +cargo install --git https://git.g4b.org/dirigence/fermata --features cli ``` -### Protect a project in 30 seconds +Requires a working [Rust toolchain](https://rustup.rs). -```bash -# Declare where secrets live -- fermata parses them and redacts values from agent output -cat > .botsecrets << 'EOF' +### Protect a project + +Create a `.botsecrets` file at your project root — the primary (and usually only) config you need: + +```toml +# .botsecrets [files] patterns = [".env", ".env.*", "secrets.*"] @@ -38,10 +63,21 @@ patterns = [".claude/**", "vendor/**", "*.lock"] [policy.bash] deny = ["rm -rf /", "curl * | sh"] -EOF ``` -One file. The agent can read `.env` freely -- fermata redacts the secret values from the output before they reach the model. Write protection and bash safety rules live in the same `.botsecrets` under `[policy]`. +One file. The agent can read `.env` freely — fermata redacts the secret values from the output before they reach the model. Write protection and bash safety rules live in the same `.botsecrets` under `[policy]`. + +fermata ships with built-in key patterns (`*_KEY`, `*_SECRET`, `*_PASSWORD`, `*_TOKEN`, `DATABASE_URL`, and ~25 more) that cover the common cases automatically. + +> **Note:** fermata also accepts `fermata.toml` as an alias for `.botsecrets` (same format, `.botsecrets` takes priority when both exist). + +Optionally, add a `.botignore` for simple path blocking using gitignore syntax: + +```gitignore +# .botignore (optional — complements .botsecrets) +vendor/ +*.lock +``` ### Wire into Claude Code @@ -70,153 +106,89 @@ Add both hooks in `.claude/settings.json`: } ``` -That's it. PostToolUse redacts secret values from tool output before they reach the LLM. PreToolUse blocks forbidden writes and dangerous commands. +PostToolUse redacts secret values from output before they reach the LLM. PreToolUse blocks forbidden writes and dangerous commands. -## How It Works +--- -fermata interposes on every tool call in the agent's lifecycle: +## What Fermata Does Not Do -``` -Agent wants to run a tool - | - PreToolUse ── .botsecrets [policy] / .botignore - | write blocked? → deny - | bash denied? → deny - | otherwise → allow (including reads of .env!) - | - Tool executes - | - PostToolUse ── .botsecrets [files] + [keys] + [heuristic] - | secret values found? → redact before LLM sees it - | - Clean output enters LLM context -``` +fermata is a heuristic guard, not a sandbox. It defends against statistical agent behavior — the unguided LLM reaching for `.env`, the overly-broad glob, the stray `cat` of a credential file. It does not defend against a deliberate adversary trying to escape the box. -Three layers of defense, each independent: +Things fermata cannot catch: -| Layer | Mechanism | What it catches | -|-------|-----------|-----------------| -| **Known-value redaction** | `.botsecrets` declares secret files; fermata parses them and builds an Aho-Corasick automaton | Every occurrence of a declared secret value, in any tool output, regardless of source | -| **Heuristic detection** | Regex patterns from gitleaks detect undeclared secrets (AWS keys, JWTs, GitHub PATs, database URLs) | Secrets not covered by the manifest -- runtime-generated, unexpected locations | -| **Access control** | `.botsecrets [policy]` / `.botignore` rules block writes and dangerous commands | Destructive writes, anti-jailbreak (agent modifying its own hooks), dangerous shell commands | +- **Network exfiltration** — an agent sending secrets via `curl` or `git push`. Use network-level controls (firewall, container networking) for this. +- **Kernel-level file access** — a process bypassing tool hooks entirely. Use container isolation or a sandbox for hard filesystem boundaries. +- **Character-by-character reconstruction** — an adversarial agent reassembling a secret across multiple tool calls. -Performance: ~1-5ms per tool call. Cold start (loading config + parsing secret files) is ~10-20ms. +These are honest boundaries, not future promises. See [docs/threat-model.md](docs/threat-model.md) for the full analysis. + +--- ## Configuration -### `.botsecrets` -- the primary (and usually only) config +**`.botsecrets`** is the primary configuration file. It declares which files contain secrets (`[files]`), how to redact them (`[redaction]`), and optionally embeds access-control policy (`[policy.write]`, `[policy.bash]`, `[policy.read]`). Most projects need only this file. `.botsecrets` can do everything `.botignore` can and more. -`.botsecrets` is the unified configuration file. It declares both what to redact and what to restrict: +**`.botignore`** uses gitignore syntax to block reads and writes. Useful for monorepo subtree exclusion or teams that prefer gitignore syntax for simple path blocking. Complements `.botsecrets` but is not required. -```toml -[files] -patterns = [".env", ".env.*", "secrets.*"] +See [docs/configuration.md](docs/configuration.md) for the full reference with examples. -[keys] -include = ["STRIPE_*", "MY_APP_SIGNING_*"] +--- -[heuristic] -enabled = true +## Status -# Access control: write protection and bash safety. -# Reading secret-containing files is allowed -- Layer 1 redacts the values. +v0.2 — secret filtering engine and policy gate are production-ready: -[policy.write] -patterns = [".claude/**", "vendor/**", "*.lock"] +| Component | Status | Maturity | +|-----------|--------|----------| +| `.botsecrets` config + `[policy]` section | Done | production | +| `.botignore` walker (gitignore semantics) | Done | production | +| Known-value redactor (Aho-Corasick) | Done | production | +| Heuristic scanner (gitleaks-derived patterns) | Done | production | +| Multi-format secret parser (.env, TOML, YAML, JSON) | Done | production | +| Claude Code PreToolUse + PostToolUse adapters | Done | production | +| CLI: `fermata check` and `fermata hook` | Done | production | -[policy.bash] -deny = ["rm -rf /", "curl * | sh"] -``` +Out of scope for v0.2: Codex / Gemini hook adapters, MCP server mode, audit log, filesystem watcher. -Built-in key patterns (`*_KEY`, `*_SECRET`, `*_PASSWORD`, `*_TOKEN`, `DATABASE_URL`, etc.) handle most projects without custom configuration. - -### `.botignore` -- optional simple layer - -Gitignore syntax. For projects that want a minimal, familiar format for write protection. Complements `.botsecrets` but is not required. - -```gitignore -vendor/** -*.lock -``` - -See [docs/configuration.md](docs/configuration.md) for the full reference. - -## Commands - -```bash -# Check if a path is allowed -fermata check --op read /path/to/.env # exit 1 = blocked -fermata check --op write src/main.rs # exit 0 = allowed - -# Run as a hook (reads harness JSON from stdin) -fermata hook --harness claude -fermata hook --harness claude --event post-tool-use -``` - -See [docs/commands.md](docs/commands.md) for the full CLI reference. - -## Library API - -fermata is also a Rust library: - -```rust -use dirigent_fermata::core::secrets::{Manifest, Redactor, Scanner, SecretsConfig}; - -// Load .botsecrets and build the redaction manifest -let config = SecretsConfig::load("/path/to/project")?; -let manifest = Manifest::discover(&config)?; - -// Known-value redaction (Aho-Corasick, sub-millisecond) -let redactor = Redactor::from_manifest(&manifest); -let clean = redactor.redact("DB_PASSWORD=hunter2"); -// -> "DB_PASSWORD=*****" - -// Heuristic scanning (regex patterns) -let scanner = Scanner::new(&config); -let findings = scanner.scan("Found key: AKIA1234567890ABCDEF"); -// -> [Finding { pattern: "AWS Access Key", confidence: High, .. }] -``` - -## Security Model - -fermata addresses a novel security concern: **reveal** -- whether secret *values* enter the LLM context. Traditional file-level access control operates on file identity (which file). Secret redaction operates on data content (which values). The reveal problem can only be solved at the data-content level. - -Read [docs/security-model.md](docs/security-model.md) for the full analysis, including the Reveal Triangle and defense-in-depth architecture. - -## Threat Model - -fermata is a heuristic guard, not a sandbox. It defends against statistical agent behavior and prompt-driven mistakes -- not a deliberate adversary. This is a strength: the threat model is well-defined, and the boundaries are documented honestly. - -Read [docs/threat-model.md](docs/threat-model.md) for what fermata catches, what it doesn't, and what to combine it with. +--- ## Harness Support | Harness | Status | Mechanism | |---------|--------|-----------| | Claude Code | Shipped | PreToolUse + PostToolUse hooks | -| Codex CLI | Planned | Pre-exec hook adapter | +| Codex CLI | Planned | Hook adapter | | Gemini CLI | Planned | MCP server mode | | Any MCP agent | Planned | MCP proxy wrapping existing servers | +| Any shell-based hook | Supported | CLI exit codes | The policy engine and redaction logic are identical across all modes. Only the I/O adapter changes. -## Status +--- -v0.2 -- secret filtering engine and policy gate are production-ready. All core components are implemented and tested: +## Background -- `.botsecrets` config with `[files]`, `[keys]`, `[heuristic]`, and `[policy]` sections -- Aho-Corasick known-value redactor -- Heuristic scanner with gitleaks-derived patterns -- Manifest discovery, multi-format parser (.env, TOML, YAML, JSON) -- Claude Code PreToolUse and PostToolUse adapters -- `.botignore` walker with gitignore semantics +fermata addresses a novel security concern — **reveal**: whether secret *values* enter the LLM context, independent of whether the agent can open a file. This distinction (file identity vs. data content) is explored in: -## The `.botsecrets` Vision +- [docs/security-model.md](docs/security-model.md) — the Reveal Triangle and defense-in-depth architecture +- [docs/threat-model.md](docs/threat-model.md) — what fermata catches at each detection level, and where it stops +- [docs/commands.md](docs/commands.md) — full CLI reference -`.botsecrets` is designed to be the **`.gitignore` of AI agent security**: a simple, declarative, human-readable file that every project can drop in to protect its secrets from AI agents. +The `.botsecrets` format is designed to be the **`.gitignore` of AI agent security**: a simple, declarative, harness-agnostic file that every project can drop in. The portable sections (`[files]`, `[keys]`, `[redaction]`, `[heuristic]`) declare *what* to protect; the `[policy]` section adds fermata-specific access control. -The format is harness-agnostic from day one. It declares *what* to protect, not *how*. One file covers both redaction (`[files]`, `[keys]`, `[heuristic]`) and access control (`[policy]`). The same `.botsecrets` works with Claude Code, Codex, Gemini, and any future harness that supports tool lifecycle hooks. +--- + +## Part of Dirigent + +Fermata is the security subsystem of [Dirigent](https://git.g4b.org/dirigence/dirigent), a multi-agent orchestration platform. It is developed in the upstream monorepo and exported here for standalone use — no other Dirigent component is required. + +--- ## License -Licensed under either of [Apache License, Version 2.0](LICENSE-APACHE) or [MIT License](LICENSE-MIT) at your option. +Licensed under either of + +- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE)) +- MIT License ([LICENSE-MIT](LICENSE-MIT)) + +at your option. diff --git a/interception-flow.svg b/interception-flow.svg new file mode 100644 index 0000000..7a1ad2f --- /dev/null +++ b/interception-flow.svg @@ -0,0 +1,72 @@ + + + How fermata intercepts + + + Agent requests a tool call + + + + + + PreToolUse — policy gate + .botignore blocks reads, writes, commands + Blocked? Operation never executes. + + + + + DENIED + + + + allowed + + + Tool executes (Read, Bash, Edit, Write) + + + + + + PostToolUse — secret redaction + .botsecrets known values → ***** + Heuristic patterns → flagged/redacted + Aho-Corasick automaton, sub-millisecond + + + + + + Clean output enters LLM context + + WHAT GETS CAUGHT + + + PreToolUse + Read .env → blocked + rm -rf / → denied + cat secrets/db.env → blocked + + + PostToolUse + DB_PASSWORD=hunter2 → DB_PASSWORD=***** + AKIA1234567890ABCDEF → ***** (heuristic) + docker-compose config → 2 values scrubbed + + + Beyond fermata — + network exfil, kernel access → sandbox + + + Performance + ~1-5ms per tool call. Cold start ~10-20ms. + + + Key insight + source .env && echo $DB_PASSWORD is caught — no file read was blocked, but the secret value is scrubbed from output. + + + Same engine, different wiring: + Hook script (Claude Code, Codex) · MCP proxy (planned) · Library API (in-process) + diff --git a/threat-landscape.svg b/threat-landscape.svg new file mode 100644 index 0000000..3e81cb9 --- /dev/null +++ b/threat-landscape.svg @@ -0,0 +1,71 @@ + + + The Reveal Triangle — why blocking the file is not enough + + THREE INDEPENDENT DIMENSIONS + + + Read + Can the agent open a file? + Operates on file identity + → .botignore handles this + + + Write + Can the agent modify a file? + Operates on file identity + → git provides recovery + + + Reveal + Do secret values enter the LLM? + Operates on data content + → nothing addresses this today + + + These are independent. An agent can have read access to .env without secret values being revealed — if output is redacted. + + HOW SECRETS REACH THE LLM DESPITE FILE BLOCKING + (from agent behavior research) + + + + Obvious — caught by file ACL + cat .env · Read(".env") · cat ~/.aws/credentials + + Blocked by .botignore + The agent names the file directly. ~90% of cases. + + + Indirect — values leak through other tools + docker-compose config (interpolates .env values) + printenv · env (shows sourced env vars) + + File was never read — values still leak + The secret appears in tool output from a command + that has nothing to do with the secret file. + + + Constructed — agent builds the path indirectly + cat $CONFIG_FILE · cat $(find . -name '.env*') + writes a script that reads .env, then runs it + + Path is hidden from static analysis + Variable expansion, command substitution, or a + two-step write-then-execute pattern. + + + Exfiltration — beyond tool-level defense + curl -d "$(cat .env)" · git push with secrets + + Needs sandbox / network controls + Out of scope by design. See sandcage. + + + fermata's coverage + + PreToolUse — blocks obvious file access (row 1) + + PostToolUse — redacts secret values from all output (rows 1–3) + File ACLs solve Read. Secret redaction solves Reveal. Both are needed because they are independent dimensions. +