From 77520819f6df4cc033de86ab7dd48e7a107bf89d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabor=20K=C3=B6rber?= Date: Mon, 25 May 2026 18:27:51 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=9D=20fermata:=20rewrite=20docs=20for?= =?UTF-8?q?=20public-facing=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New user-friendly README modeled after sandcage's layout (Why / Quick Start / How It Works), plus four focused docs under docs/: - commands.md — full CLI reference with options, exit codes, examples - configuration.md — .botignore, botignore.toml, .botsecrets reference - security-model.md — the Reveal Triangle and defense-in-depth layers - threat-model.md — L0-L6 coverage, honest limitations, pairing guidance All Dirigent/monorepo internals stripped — ready for standalone export. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 245 ++++++++++++---------- docs/commands.md | 189 +++++++++++++++++ docs/configuration.md | 465 +++++++++++++++++++++++++++++++++++++++++ docs/security-model.md | 96 +++++++++ docs/threat-model.md | 142 +++++++++++++ 5 files changed, 1030 insertions(+), 107 deletions(-) create mode 100644 docs/commands.md create mode 100644 docs/configuration.md create mode 100644 docs/security-model.md create mode 100644 docs/threat-model.md diff --git a/README.md b/README.md index 84ffbee..515ccff 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,44 @@ -# dirigent_fermata +# fermata -**A fast, harness-agnostic policy gate and secret filtering engine for AI coding agents.** +**A fast, harness-agnostic security layer for AI coding agents.** -Drop a `.botignore` to control what your agent can touch. Drop a `.botsecrets` to control what secret values your agent can see. Fermata enforces both -- before and after tool calls happen. +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. No AI coding agent ships built-in post-read secret filtering today. fermata fixes that. ---- +## Why -## Why Fermata +Traditional security blocks the file and hopes the agent doesn't find the data through another path. This is insufficient -- secrets appear in shell output, log files, error messages, and indirect reads that bypass any access-control list. -AI coding agents don't have an innate sense of "don't touch `.env`" -- and even if you block the file, they can still see its contents through shell output, log files, and indirect reads. Fermata solves both problems: +fermata operates on two independent levels: -- **Policy gate** -- `.botignore` blocks reads, writes, and dangerous commands before they execute (PreToolUse). -- **Secret filtering** -- `.botsecrets` redacts secret values from tool output before they enter the LLM context (PostToolUse). -- **Fast** -- Rust, Aho-Corasick automaton for redaction, ~1-5ms per call. -- **Familiar syntax** -- `.botignore` uses gitignore rules; `.botsecrets` uses TOML with glob patterns. -- **Harness-agnostic** -- hook adapters for Claude Code (shipped), Codex and Gemini (planned), MCP proxy (planned). +- **Policy gate** (PreToolUse) -- `.botignore` blocks reads, writes, and dangerous commands before they execute. Catches ~90% of accidental secret access. +- **Secret filtering** (PostToolUse) -- `.botsecrets` redacts secret *values* from tool output before they enter the LLM context. Catches the remaining cases regardless of how secrets appear. ---- +The key insight: blocking a file is necessary but not sufficient. The agent can have read access to `.env` without secret values being revealed -- if the output is redacted before it reaches the model. -## Status: v0.2 +## Quick Start -| Component | Status | -|-----------|--------| -| Library (`Policy::check`, `Policy::check_command`) | Done | -| `.botignore` walker (gitignore semantics) | Done | -| `botignore.toml` parser (read / write / bash namespaces) | Done | -| CLI: `fermata check` / `fermata hook` | Done | -| Claude Code PreToolUse adapter | Done | -| Claude Code PostToolUse adapter (output redaction) | Done | -| `.botsecrets` config parser | Done | -| Secret manifest discovery and loading | Done | -| Multi-format secret file parser (.env, TOML, YAML, JSON) | Done | -| `Redactor` (known-value Aho-Corasick replacement) | Done | -| `Scanner` (heuristic regex + gitleaks patterns) | Done | - -Out of scope for v0.2: Codex / Gemini hook adapters, MCP proxy mode, audit log, filesystem watcher. - ---- - -## Install - -From source (this monorepo): +### Install ```bash -cargo install --path crates/dirigent_fermata --features cli +cargo install --path . --features cli ``` ---- +### Protect a project in 30 seconds -## Secret Filtering +```bash +# Block direct access to secret files +echo ".env" > .botignore -Fermata's secret filtering operates in three layers: - -1. **Policy gate** (PreToolUse) -- `.botignore` blocks direct access to sensitive files. Catches ~90% of accidental reads. -2. **Known-value redaction** (PostToolUse) -- `.botsecrets` declares which files contain secrets. Fermata parses them, extracts values, and replaces them in all tool output using an Aho-Corasick automaton. Zero false negatives for declared secrets. -3. **Heuristic scanning** (PostToolUse) -- regex patterns derived from gitleaks detect undeclared secrets (AWS keys, JWTs, GitHub PATs, database URLs). Safety net for secrets not covered by the manifest. - -### `.botsecrets` format - -Create a `.botsecrets` file at your project root: - -```toml -# Files that contain secrets -- fermata parses these and redacts values +# Declare where secrets live -- fermata parses them and redacts values +cat > .botsecrets << 'EOF' [files] patterns = [".env", ".env.*", "secrets.*"] - -# Additional secret key names (built-in defaults cover *_KEY, *_SECRET, etc.) -[keys] -include = ["STRIPE_*", "MY_APP_SIGNING_*"] - -# Heuristic scanning on all tool output -[heuristic] -enabled = true +EOF ``` -That's the typical case. Built-in key patterns (`*_KEY`, `*_SECRET`, `*_PASSWORD`, `*_TOKEN`, `DATABASE_URL`, etc.) handle most projects without custom configuration. +### Wire into Claude Code ---- - -## Usage - -### Claude Code hook configuration - -Add both PreToolUse and PostToolUse hooks in `.claude/settings.json`: +Add both hooks in `.claude/settings.json`: ```json { @@ -107,45 +63,43 @@ Add both PreToolUse and PostToolUse hooks in `.claude/settings.json`: } ``` -PreToolUse blocks forbidden operations. PostToolUse redacts secret values from tool output before they reach the LLM. +That's it. PreToolUse blocks forbidden operations. PostToolUse redacts secret values from tool output before they reach the LLM. -### Checking a path +## How It Works -```bash -fermata check --op read /path/to/.env -# exit 1 -- blocked +fermata interposes on every tool call in the agent's lifecycle: -fermata check --op write /path/to/src/main.rs -# exit 0 -- allowed +``` +Agent wants to run a tool + | + PreToolUse ── fermata checks .botignore / botignore.toml + | blocked? → deny with reason + | allowed? ↓ + Tool executes + | + PostToolUse ── fermata scans output for secret values + | found? → replace with ***** before LLM sees it + | + Clean output enters LLM context ``` -### Library API +Three layers of defense, each independent: -```rust -use dirigent_fermata::core::secrets::{Manifest, Redactor, Scanner, SecretsConfig}; +| Layer | Mechanism | What it catches | +|-------|-----------|-----------------| +| **Access control** | `.botignore` rules block tool calls by path | Direct reads/writes to sensitive files | +| **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 | -// Load .botsecrets config and build the 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\nAPI_KEY=sk-abc123"); -// -> "DB_PASSWORD=*****\nAPI_KEY=*****" - -// Heuristic scanning (regex patterns) -let scanner = Scanner::new(&config); -let findings = scanner.scan("Found key: AKIA1234567890ABCDEF"); -// -> [Finding { pattern: "AWS Access Key", confidence: High, .. }] -``` - ---- +Performance: ~1-5ms per tool call. Cold start (loading config + parsing secret files) is ~10-20ms. ## Configuration -### `.botignore` -- access control +Three files, each optional, each solving a different problem: -Gitignore syntax. Blocks both reads and writes. +### `.botignore` -- the 80% case + +Gitignore syntax. Blocks both reads and writes. Onboarding is one line. ```gitignore .env @@ -155,6 +109,8 @@ secrets/** ### `botignore.toml` -- per-operation rules +Separate namespaces so the same file can be readable but not writable: + ```toml [read] patterns = [".env*", "secrets/**"] @@ -168,24 +124,99 @@ deny = ["rm -rf /", "curl * | sh"] ### `.botsecrets` -- secret value redaction -See the Secret Filtering section above. +Declares which files contain secrets. fermata parses them, extracts values, and redacts every occurrence in tool output. ---- +```toml +[files] +patterns = [".env", ".env.*", "secrets.*"] -## Architecture +[keys] +include = ["STRIPE_*", "MY_APP_SIGNING_*"] -Three concentric layers; nothing inner imports from anything outer: +[heuristic] +enabled = true +``` -- **`core/`** -- harness-unaware, sync. Policy types, `.botignore` walker, `botignore.toml` parser, `Policy::check`. - - **`core/secrets/`** -- `.botsecrets` config, manifest discovery, multi-format parser, Aho-Corasick redactor, heuristic scanner. -- **`harness/`** -- `HarnessAdapter` trait for PreToolUse (policy gate) and PostToolUse (output redaction). Each adapter is feature-gated. -- **`bin/fermata.rs`** -- `clap`, stdio, and exit codes. +Built-in key patterns (`*_KEY`, `*_SECRET`, `*_PASSWORD`, `*_TOKEN`, `DATABASE_URL`, etc.) handle most projects without custom configuration. ---- +See [docs/configuration.md](docs/configuration.md) for the full reference. -## See also +## Commands -- `docs/tools/fermata.md` -- Dirigent integration plan -- `docs/architecture/fermata-security-philosophy.md` -- security philosophy and the reveal triangle -- `docs/workpad/brainstorm/fermata.md` -- full product spec and field notes -- `docs/architecture/crates.md` -- crate dependency map +```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 | +| Gemini CLI | Planned | MCP server mode | +| Any MCP agent | Planned | MCP proxy wrapping existing servers | + +The policy engine and redaction logic are identical across all modes. Only the I/O adapter changes. + +## Status + +v0.2 -- policy gate and secret filtering engine are production-ready. All core components are implemented and tested: + +- `.botignore` walker with gitignore semantics +- `botignore.toml` with read/write/bash namespaces +- Claude Code PreToolUse and PostToolUse adapters +- `.botsecrets` config, manifest discovery, multi-format parser (.env, TOML, YAML, JSON) +- Aho-Corasick known-value redactor +- Heuristic scanner with gitleaks-derived patterns + +## The `.botsecrets` Vision + +`.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 format is harness-agnostic from day one. It declares *what* to protect, not *how*. The same `.botsecrets` works with Claude Code, Codex, Gemini, and any future harness that supports tool lifecycle hooks. + +## License + +Licensed under either of [Apache License, Version 2.0](LICENSE-APACHE) or [MIT License](LICENSE-MIT) at your option. diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..ce07337 --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,189 @@ +# CLI Command Reference + +Fermata is a policy gate and secret filtering engine for AI coding agents. It ships two subcommands: `check` for interactive path validation and `hook` for integration with AI harness hook systems. + +--- + +## fermata check + +Check whether one or more paths are allowed for a given operation. Fermata locates the nearest project root (a directory containing `.botignore`, `botignore.toml`, or `.git`) and evaluates the policy defined there. + +**Usage** + +``` +fermata check [OPTIONS] ... +``` + +**Options** + +| Option | Description | Default | +|--------|-------------|---------| +| `--op ` | Operation to check: `read`, `write`, or `execute` | `read` | +| `--json` | Print the full decision as JSON instead of a human message | off | + +**Exit codes** + +| Code | Meaning | +|------|---------| +| 0 | Allowed (or Ask -- the agent may prompt the user) | +| 1 | Denied -- the policy blocks this operation | +| 2 | Internal error (could not load policy, bad arguments, etc.) | + +When multiple paths are provided, fermata evaluates each one independently and returns the **worst** result. If any path is denied, the exit code is 1. + +**Examples** + +```bash +# Check if the agent can read .env +fermata check --op read .env + +# Check if the agent can write to a source file +fermata check --op write src/main.rs + +# Check multiple paths at once; exits 1 if any are denied +fermata check --op read .env src/main.rs config/secrets.yaml + +# Get a machine-readable JSON decision +fermata check --op read .env --json +``` + +--- + +## fermata hook + +Read a harness hook payload from stdin, evaluate the policy (PreToolUse) or redact secrets from tool output (PostToolUse), and write the response to stdout. This is the command you wire into your AI agent's hook configuration. + +**Usage** + +``` +fermata hook [OPTIONS] +``` + +The hook payload is always read from **stdin** as JSON. The response is written to **stdout** as JSON. + +**Options** + +| Option | Description | Default | +|--------|-------------|---------| +| `--event ` | Hook event type. Accepted values: `pre-tool-use`, `PreToolUse`, `post-tool-use`, `PostToolUse` | `pre-tool-use` | +| `--harness ` | Harness adapter name. Currently supported: `claude` | `claude` | + +**Exit codes** + +| Code | Meaning | +|------|---------| +| 0 | Success. The JSON response on stdout tells the harness what to do. | +| 2 | Internal error (unknown harness, unknown event type, stdin read failure) | + +Fermata follows a **fail-open** policy for hooks: if the payload cannot be parsed or the policy cannot be loaded, it writes `{}` to stdout and exits 0 so the agent can continue. Errors are reported on stderr. + +**Event types** + +- **pre-tool-use** -- Runs before the tool executes. Fermata checks the requested path or command against `.botignore` / `botignore.toml` and returns an allow, deny, or ask decision to the harness. +- **post-tool-use** -- Runs after the tool executes. Fermata loads `.botsecrets`, builds a manifest of known secret values, and redacts any matches from the tool output before it enters the LLM context. A heuristic scanner (regex patterns derived from gitleaks) catches undeclared secrets as a safety net. + +**Examples** + +```bash +# PreToolUse: pipe a Claude Code hook payload through fermata +echo '{"tool_name":"Read","tool_input":{"file_path":"/project/.env"}}' \ + | fermata hook --harness claude + +# PostToolUse: redact secrets from tool output +echo '{"tool_name":"Bash","tool_input":{"command":"cat .env"},"tool_output":"API_KEY=sk-live-abc123"}' \ + | fermata hook --harness claude --event post-tool-use +``` + +--- + +## Configuration Files + +Fermata does not have its own global config file. All configuration lives in your project directory alongside the code: + +| File | Purpose | +|------|---------| +| `.botignore` | Gitignore-syntax patterns. Blocks both reads and writes to matched paths. | +| `botignore.toml` | Per-operation rules with `[read]`, `[write]`, and `[bash]` sections. | +| `.botsecrets` | TOML file declaring which files contain secrets, custom key patterns, and heuristic scanning options. | + +Fermata discovers these files by walking up from the target path (or the current working directory for `hook`) until it finds a project root. + +--- + +## Common Patterns + +### Protect secrets from day one + +Drop two files in your project root: + +```gitignore +# .botignore +.env +.env.* +secrets/** +credentials/** +``` + +```toml +# .botsecrets +[files] +patterns = [".env", ".env.*"] + +[heuristic] +enabled = true +``` + +The `.botignore` blocks direct reads. The `.botsecrets` catches secrets that leak through indirect paths (shell output, log files, error messages). + +### Wire fermata into Claude Code + +Add both hook events to `.claude/settings.json`: + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash|Read|Edit|Write", + "hooks": [ + { "type": "command", "command": "fermata hook --harness claude" } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Bash|Read|Edit|Write", + "hooks": [ + { "type": "command", "command": "fermata hook --harness claude --event post-tool-use" } + ] + } + ] + } +} +``` + +### Block dangerous shell commands + +Use `botignore.toml` to deny specific command patterns: + +```toml +[bash] +deny = ["rm -rf /", "curl * | sh", "wget * | bash"] +``` + +### Quick smoke test + +After installing fermata, verify it works against your project: + +```bash +# Should exit 0 (allowed) +fermata check --op read src/main.rs +echo $? + +# Should exit 1 (denied) if .botignore blocks .env +fermata check --op read .env +echo $? + +# JSON output for scripting +fermata check --op read .env --json +``` diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..b15d5c3 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,465 @@ +# Configuration Reference + +Fermata uses three configuration files to control what an AI coding agent can +access and what secret values it can see. Configuration is layered, with later +(more specific) sources overriding earlier ones. + +| File | Purpose | Syntax | +|------|---------|--------| +| `.botignore` | Block agent access to matching paths (reads and writes) | gitignore | +| `botignore.toml` | Fine-grained per-operation rules (read, write, bash) | TOML | +| `.botsecrets` | Declare secret-containing files and control redaction | TOML | + +All three files are optional. Without any configuration Fermata allows all +operations and performs no redaction. Add only the files you need. + +--- + +## `.botignore` -- Path-Based Access Control + +A `.botignore` file uses **gitignore syntax** to block agent access to matching +paths. When a path matches, both reads and writes are denied. There is no +distinction between operations -- if you need per-operation control, use +`botignore.toml` instead. + +### Placement and Scoping + +`.botignore` files can be placed at any level of your project directory tree. +Fermata walks the project root recursively and loads every `.botignore` it +finds. Each file is scoped to its own directory: + +``` +myproject/ + .botignore # applies to the entire project + infra/ + .botignore # applies only under infra/ + src/ + .botignore # applies only under src/ +``` + +When multiple `.botignore` files match the same path, the **deepest +(most specific) file wins**. A negation pattern (`!`) at any depth overrides +an ignore from a shallower `.botignore`. + +### Syntax + +Standard gitignore rules apply: + +- Blank lines and lines starting with `#` are ignored. +- `*` matches anything except `/`. +- `**` matches zero or more directories. +- A trailing `/` matches directories only. +- A leading `/` anchors the pattern to the `.botignore` file's directory. +- Prefix a pattern with `!` to negate (whitelist) a previously ignored path. + +### Example + +```gitignore +# Block all environment files +.env +.env.* + +# Block the entire secrets directory +secrets/** + +# Block private keys +*.pem +*.key +id_rsa +id_ed25519 + +# But allow the public key +!id_ed25519.pub +``` + +--- + +## `botignore.toml` -- Per-Operation Rules + +`botignore.toml` provides fine-grained control by separating rules into three +namespaces: `[read]`, `[write]`, and `[bash]`. Place this file at the project +root alongside `.botignore`. + +When both `.botignore` and `botignore.toml` are present, they are evaluated +together. `.botignore` is checked first (blocking both reads and writes), then +`botignore.toml` namespace-specific rules are applied. A path blocked by either +source is denied. + +### `[read]` -- Read Access Rules + +Blocks the agent from reading matching paths. Patterns use glob syntax. + +| Field | Type | Description | +|-------|------|-------------| +| `patterns` | `string[]` | Glob patterns for paths the agent cannot read. | + +```toml +[read] +patterns = [".env*", "secrets/**", "*.pem"] +``` + +### `[write]` -- Write Access Rules + +Blocks the agent from writing to matching paths. Patterns use glob syntax. + +| Field | Type | Description | +|-------|------|-------------| +| `patterns` | `string[]` | Glob patterns for paths the agent cannot write to. | + +```toml +[write] +patterns = ["vendor/**", "*.lock", "migrations/**"] +``` + +### `[bash]` -- Command Execution Rules + +Controls which shell commands the agent can run. Commands are evaluated in +priority order: deny, then allow_prefixes, then ask. If none match, the +command is allowed. + +| Field | Type | Description | +|-------|------|-------------| +| `deny` | `string[]` | Patterns that block commands outright. Substring match by default; glob metacharacters (`*`, `?`, `[`) enable glob matching. | +| `allow_prefixes` | `string[]` | Command prefixes that are always allowed. If a command starts with a prefix, it passes immediately (before `ask` is checked). Trailing `:*` is stripped before matching. | +| `ask` | `string[]` | Patterns that require user confirmation before executing. Same matching rules as `deny`. | + +Evaluation order: + +1. If any `deny` pattern matches, the command is **blocked**. +2. If any `allow_prefixes` entry matches, the command is **allowed**. +3. If any `ask` pattern matches, the command **requires confirmation**. +4. Otherwise the command is **allowed**. + +```toml +[bash] +deny = ["rm -rf /", "curl * | sh", ":(){ :|:& };:"] +allow_prefixes = ["cargo", "npm", "git status"] +ask = ["docker", "kubectl"] +``` + +### Full `botignore.toml` Example + +```toml +[read] +patterns = [".env*", "secrets/**"] + +[write] +patterns = ["vendor/**", "*.lock", "dist/**"] + +[bash] +deny = ["rm -rf /", "curl * | sh"] +allow_prefixes = ["cargo", "npm", "just"] +ask = ["docker", "kubectl", "terraform"] +``` + +--- + +## `.botsecrets` -- Secret Redaction + +`.botsecrets` declares which files contain secrets and how Fermata should +redact them from tool output. This prevents secret values from leaking into +the LLM context window even when the agent reads files indirectly (via shell +output, log files, error messages, etc.). + +### Layered Configuration + +`.botsecrets` configuration is layered, with later sources overriding earlier +ones: + +1. **Built-in defaults** -- sensible patterns that cover common secret files + and key names. +2. **User-global** -- `~/.config/fermata/.botsecrets` (Linux/macOS) or + `%APPDATA%\fermata\.botsecrets` (Windows). Applies to all projects. +3. **Project** -- `/.botsecrets`. Checked into version control. +4. **Local overrides** -- `/.botsecrets.local`. Git-ignored, + for machine-specific or developer-specific settings. + +**Merge rules:** + +- **Vec fields** (`files.patterns`, `heuristic.patterns`, `file_overrides`): + **replaced** by the more specific layer when present. +- **Key lists** (`keys.include`, `keys.exclude`): **accumulated** across + layers (appended, not replaced). +- **Scalar fields** (`redaction.style`, `heuristic.enabled`, `enforcement.mode`, + etc.): the most specific value wins. + +### `[files]` -- Secret File Patterns + +Declares which files contain secrets. Fermata parses these files, extracts +key-value pairs, and uses the values for exact-match redaction. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `patterns` | `string[]` | See below | Glob patterns matching files that contain secrets. | + +**Built-in default patterns** (active when `[files]` is not specified): + +``` +.env, .env.*, *.env, secrets.*, credentials.*, *.key, *.pem, *.p12, *.pfx, +id_rsa, id_ed25519, id_ecdsa, Secrets.toml, Secrets.*.toml, +terraform.tfvars, *.auto.tfvars, terraform.tfstate, *.tfstate, +.docker/config.json, config/master.key, config/credentials/*.key, +.aws/credentials, .netrc, .htpasswd, service-account.json, +service-account-key.json +``` + +Setting `files.patterns` in a layer **replaces** the defaults entirely. + +```toml +[files] +patterns = [".env", ".env.*", "config/secrets.yaml"] +``` + +### `[keys]` -- Secret Key Name Patterns + +Controls which key names within secret files are treated as sensitive. +Patterns use glob syntax and are matched case-insensitively. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `include` | `string[]` | `[]` | Additional key name patterns to treat as secret. Accumulated across layers. | +| `exclude` | `string[]` | `[]` | Key name patterns to remove from the effective set. Exact string match against the pattern text. | + +Fermata ships with ~30 built-in key patterns that are always active: + +``` +*PASSWORD*, *SECRET*, *API_KEY*, *APIKEY*, *TOKEN*, *ACCESS_KEY*, +*PRIVATE_KEY*, *AUTH*, *CREDENTIAL*, *CONNECTION_STRING*, DATABASE_URL, +AWS_SECRET_ACCESS_KEY, GITHUB_TOKEN, OPENAI_API_KEY, ANTHROPIC_API_KEY, +JWT_SECRET, ENCRYPTION_KEY, MASTER_KEY, SECRET_KEY_BASE, ... +``` + +Use `keys.include` to add project-specific patterns. Use `keys.exclude` to +suppress a built-in pattern that causes false positives. + +```toml +[keys] +include = ["STRIPE_*", "MY_APP_SIGNING_*"] +exclude = ["*AUTH*"] # too broad for this project +``` + +### `[redaction]` -- Redaction Style + +Controls how redacted values appear in tool output. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `style` | `string` | `"masked"` | How to replace secret values. | + +Available styles: + +| Style | Output | Description | +|-------|--------|-------------| +| `masked` | `*****` | Replaces the value with asterisks. Default. | +| `typed` | `` | Shows the value type but not the content. | +| `named` | `` | Shows the key name but not the value. | +| `absent` | *(empty string)* | Removes the value entirely. | + +```toml +[redaction] +style = "named" +``` + +### `[heuristic]` -- Heuristic Secret Scanning + +In addition to known-value redaction, Fermata can scan all tool output for +patterns that look like secrets (AWS keys, JWTs, GitHub PATs, high-entropy +strings, database connection URLs). This catches secrets not covered by the +`.botsecrets` manifest. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `enabled` | `bool` | `true` | Enable or disable heuristic scanning. | +| `mode` | `string` | `"enforce"` | What to do when a heuristic match is found. | +| `patterns` | `string[]` | `[]` | Additional regex patterns to scan for. Replaces the layer's custom patterns when set. | + +Available modes: + +| Mode | Behavior | +|------|----------| +| `enforce` | Redact heuristic matches from tool output. Default. | +| `report` | Log findings but do not redact. | +| `disabled` | Do not run heuristic scanning at all. | + +```toml +[heuristic] +enabled = true +mode = "enforce" +patterns = ["MYAPP-[A-Za-z0-9]{32}"] +``` + +### `[enforcement]` -- Enforcement Behavior + +Controls how strictly Fermata enforces redaction, especially in edge cases. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `mode` | `string` | `"permissive"` | Global enforcement strictness. | +| `on_parse_error` | `string` | `"mask-entire-file"` | What to do when a secret file cannot be parsed. | + +Enforcement modes: + +| Mode | Behavior | +|------|----------| +| `strict` | Any error or ambiguity results in denial. | +| `permissive` | Best-effort redaction; non-fatal errors are tolerated. Default. | +| `audit` | Log all decisions but do not block or redact. | + +Parse error actions: + +| Action | Behavior | +|--------|----------| +| `mask-entire-file` | Treat the entire file content as a secret. Default and safest. | +| `allow` | Skip the unparseable file (secrets may leak). | +| `deny` | Block all access to the file. | + +```toml +[enforcement] +mode = "strict" +on_parse_error = "deny" +``` + +### `[[file]]` -- Per-File Overrides + +Override parsing behavior for specific secret files. Useful when a file uses a +non-standard format or you only want to redact specific keys. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `path` | `string` | Yes | Path to the file (relative to project root). | +| `format` | `string` | No | Force a specific parser: `"env"`, `"toml"`, `"yaml"`, `"json"`. Auto-detected if omitted. | +| `keys` | `string[]` | No | Only redact these specific keys from this file (instead of applying global key patterns). | + +```toml +[[file]] +path = "config/database.yml" +format = "yaml" +keys = ["password", "secret_key_base"] + +[[file]] +path = ".env.production" +format = "env" +``` + +--- + +## Examples + +### Minimal: Just Block Sensitive Files + +If you only need to prevent the agent from reading certain files, a single +`.botignore` is enough: + +```gitignore +# .botignore +.env +.env.* +*.pem +*.key +``` + +### Standard: Block Access and Redact Secrets + +The most common setup. Block direct access with `.botignore` and redact leaked +values with `.botsecrets`: + +```gitignore +# .botignore +.env +.env.* +secrets/** +*.pem +``` + +```toml +# .botsecrets +[files] +patterns = [".env", ".env.*", "secrets/*.yaml"] + +[keys] +include = ["STRIPE_*"] + +[redaction] +style = "masked" +``` + +### Fine-Grained: Separate Read, Write, and Bash Rules + +Use `botignore.toml` when you need different rules for different operations: + +```toml +# botignore.toml +[read] +patterns = [".env*", "secrets/**"] + +[write] +patterns = ["vendor/**", "*.lock", "Cargo.toml"] + +[bash] +deny = ["rm -rf *"] +allow_prefixes = ["cargo", "npm", "git"] +ask = ["docker", "kubectl"] +``` + +### Full-Featured: All Three Files + +A production setup using all configuration files together: + +```gitignore +# .botignore +.env +.env.* +*.pem +*.key +id_rsa +id_ed25519 +``` + +```toml +# botignore.toml +[read] +patterns = ["secrets/**", ".aws/**"] + +[write] +patterns = ["vendor/**", "*.lock", "dist/**", "migrations/**"] + +[bash] +deny = ["rm -rf /", "curl * | sh"] +allow_prefixes = ["cargo", "npm", "just", "git"] +ask = ["docker", "terraform"] +``` + +```toml +# .botsecrets +[files] +patterns = [".env", ".env.*", "config/credentials.yaml"] + +[keys] +include = ["STRIPE_*", "PLAID_*"] +exclude = ["*AUTH*"] + +[redaction] +style = "named" + +[heuristic] +enabled = true +mode = "enforce" + +[enforcement] +mode = "strict" +on_parse_error = "mask-entire-file" + +[[file]] +path = "config/credentials.yaml" +format = "yaml" +keys = ["api_key", "webhook_secret"] +``` + +```toml +# .botsecrets.local (git-ignored, developer-specific) +[redaction] +style = "masked" + +[enforcement] +mode = "permissive" +``` diff --git a/docs/security-model.md b/docs/security-model.md new file mode 100644 index 0000000..6eae367 --- /dev/null +++ b/docs/security-model.md @@ -0,0 +1,96 @@ +# fermata Security Model + +## The Problem + +AI coding agents see secrets. Not because they try to -- because secrets are everywhere in a working codebase, and agents read files, run commands, and inspect output as part of their normal workflow. + +When an agent reads `.env`, the secret values get tokenized into the LLM's context window. Once there, they can leak into commits, PR descriptions, log messages, or API calls the agent makes. The secret is irrecoverably revealed. + +No AI coding agent ships built-in post-read secret filtering today. The entire industry relies on pre-read access controls -- block the file and hope the agent doesn't find the data through another path. This is insufficient. + +## The Reveal Triangle + +Most security models think about secrets in terms of *files*: can the agent read this file? Can it write to it? fermata introduces a third dimension that traditional models miss entirely. + +**Read** -- Can the agent open a file? Handled by policy rules and file access controls. A blunt instrument: blocking `.env` also blocks legitimate tooling that needs configuration values. + +**Write** -- Can the agent modify a file? Less concerning than it appears, because version control provides full recovery. Write restrictions matter primarily for anti-jailbreak (preventing the agent from modifying its own hooks or policy files). + +**Reveal** -- Do secret *values* enter the LLM context? This is the novel concern. No traditional security model addresses it because the concept did not exist before AI agents. A file read that configures a database is not a security event. The same file read that feeds credentials to an LLM *is*. + +These three dimensions are independent. An agent can have read access to `.env` without the secret values being revealed -- if the output is redacted before it reaches the model. This is the target state: the agent sees file structure, knows which keys exist, can reason about configuration, but never sees actual secret values. + +The key insight is that Read and Write operate on file identity (*which file*). Reveal operates on data content (*which values*). The reveal problem can only be solved at the data-content level. File identity is necessary but not sufficient. + +## Defense in Depth + +fermata implements a layered security stack. Each layer catches what the layers above it miss. + +### Layer 1: Access Control (.botignore) + +Block direct operations on sensitive files. `Read .env` -- denied. `Bash: rm -rf /` -- denied. + +This is the 90% mistake avoider. It catches obvious agent operations on files that policy says are off-limits. It uses gitignore-style patterns, so the syntax is already familiar. + +**Limitation**: Cannot catch indirect access. An agent running `source .env && echo $DB_PASSWORD` bypasses file-level controls entirely. This is why access control alone is not enough. + +### Layer 2: Known-Value Redaction (.botsecrets + Aho-Corasick) + +Parse secret-containing files at startup, extract actual secret values, build an Aho-Corasick automaton. Scan all tool output for those exact byte strings and replace them with redaction markers. + +This catches secrets regardless of how they appear in output -- direct file reads, shell command output, log files, error messages. If the value `hunter2` is a declared secret, every occurrence in every tool output is redacted before it reaches the model. + +**Guarantees**: Zero false negatives for declared secrets. Sub-millisecond per scan. The Aho-Corasick automaton finds all occurrences in a single linear pass over the output. + +### Layer 3: Heuristic Detection (Scanner + gitleaks patterns) + +Regex patterns for known secret formats: AWS access keys (`AKIA...`), GitHub PATs (`ghp_...`), JWTs (`eyJ...`), database URLs with embedded passwords, and dozens more derived from industry-standard pattern sets. + +This is the safety net for secrets not covered by the manifest -- secrets in files that `.botsecrets` does not know about, secrets generated at runtime, secrets that appear in unexpected places. + +Higher false-positive rate than Layer 2, but as a secondary safety net, that tradeoff is acceptable. + +### Layer 4: Structural Containment (External) + +Container-level isolation, filesystem restrictions, dropped capabilities, no-new-privileges. Prevents system modification, privilege escalation, and escape from the execution environment. This layer has no concept of secret values -- it operates on structural boundaries. + +fermata does not own this layer. It is provided by your container runtime, VM, or sandboxing tool. fermata's design assumes that structural containment exists as the outermost boundary, and focuses on the data-content layers (2 and 3) that containment cannot address. + +## Design Principles + +**Fail-open for availability.** A parse error in `.botsecrets`, an unrecognized file format, or a scanner timeout does not block the agent from working. Redaction failures are logged, not fatal. The agent's productivity is not sacrificed for edge-case security failures. + +**Zero false negatives for declared secrets.** If a value appears in the secret manifest (loaded from files matched by `.botsecrets`), it will be redacted from every tool output, every time. The Aho-Corasick automaton guarantees this -- it finds all occurrences in a single pass at memory-bandwidth speed. + +**Sub-millisecond performance.** Hooks fire on every tool call. fermata must not introduce perceptible latency. Policy evaluation is a hashmap lookup. The Aho-Corasick scan runs at memory-bandwidth speed. Cold start (loading secrets + parsing files) takes roughly 10--20ms; subsequent calls are 1--3ms. + +**Harness-agnostic.** fermata does not assume any specific AI coding agent. The policy engine is pure logic. Harness adapters are thin translation layers. The `.botsecrets` format works identically whether fermata runs as a hook script, an MCP proxy, or an in-process library. + +**Single policy file.** `.botsecrets` is the user-facing interface. One file declares what to protect. How protection is delivered is a deployment concern, not a configuration concern. + +## The .botsecrets Vision + +`.botsecrets` is designed to be the `.gitignore` of AI agent security: a simple, declarative, human-readable file that any project can drop in to protect its secrets from AI agents. + +The format is harness-agnostic from day one. It declares *what* to protect, not *how*. The "how" is determined by the delivery mode -- hook script, MCP proxy, or library. This means the same `.botsecrets` works with Claude Code, Codex, Gemini CLI, Cursor, and any future tool that supports lifecycle hooks or MCP. + +A typical `.botsecrets` looks like this: + +```toml +# Declare files that contain secrets. +# fermata parses them, extracts the values, and redacts those values +# from all tool output before it reaches the model. + +[files] +patterns = [".env", ".env.*", "config/credentials.toml", "secrets/*.yaml"] + +[keys] +include = ["STRIPE_*", "MY_APP_SIGNING_*"] + +[heuristic] +enabled = true +``` + +No secret values appear in `.botsecrets` itself. It points to the files that contain them. The secret extraction, automaton construction, and output scanning happen automatically. Built-in key patterns (`*_KEY`, `*_SECRET`, `*_PASSWORD`, `*_TOKEN`, `DATABASE_URL`, etc.) handle most projects without custom `[keys]` configuration. + +fermata is the reference implementation. The format is the standard -- fermata is one way to enforce it. diff --git a/docs/threat-model.md b/docs/threat-model.md new file mode 100644 index 0000000..3e87de3 --- /dev/null +++ b/docs/threat-model.md @@ -0,0 +1,142 @@ +# Fermata Threat Model + +What fermata catches, what it doesn't, and why we tell you both. + +--- + +## Framing: heuristic guard, not sandbox + +Fermata is **not** a defence against a deliberate, optimising adversary trying to escape the box. It is a defence against: + +- **Statistical agent behaviour** -- the playbook of moves an LLM-driven coding agent reaches for when solving a class of tasks, including its mistakes: overly-broad globs, "let me just `cat` everything", picking up a stray `.env` because it pattern-matched the rest of the directory. +- **Non-malicious harnesses** -- Claude Code, Codex, Gemini and similar agents that are not actively trying to circumvent policy. +- **Prompt-driven mistakes** -- the user's prompt or a tool's output nudges the model into touching something it shouldn't, without anyone meaning to. + +This shifts the design centre from "what can a smart attacker hide?" to "what does an unguided LLM commonly do, and how do we catch the dangerous fraction of that?" Static analysis becomes tractable when grounded in **what is usual**, not what is adversarially possible. + +Think of fermata as a guardrail on a mountain road, not an armoured wall. It catches the statistical case -- the distracted driver drifting toward the edge -- not the determined off-roader who drives around it. + +--- + +## What fermata catches + +Fermata's detection is organised by how the agent names the path or secret it is about to access. Lower levels are easier to detect statically; higher levels require progressively more runtime information. + +### L0: Direct path arguments -- fully covered + +The agent calls a path-typed tool (Read, Write, Edit) with an explicit file path. This is fermata's home turf. + +> `Read({"file_path": "/home/user/.env"})` -- `.botignore` matches -- deny. + +Both `.botignore` (gitignore semantics, deepest directory wins, negation patterns work as in git) and `botignore.toml` `[read]`/`[write]` glob lists are evaluated against the resolved path. This covers the overwhelming majority of accidental secret-file reads, because most agents use direct path tools as their primary file access method. + +### L1: Absolute paths inside Bash commands + +The agent runs a shell command that mentions a full path -- one containing a directory separator or drive letter. + +> `cat /home/user/.env`, `type C:\Users\me\secret.toml` + +Fermata's path extraction engine recognises these as high-confidence path candidates and checks them against the same policy rules as L0. Windows paths are handled conservatively (backslashes appear in many non-path contexts), but the common cases are covered. + +### L2: Bare filenames resolvable in the working directory + +The agent names a file without any path separator, relying on the shell's working directory to resolve it. + +> `cat .env`, `cat README.md` + +These are flagged as low-confidence path candidates. When the agent's working directory is known, fermata resolves bare filenames against it and evaluates them through the policy. Low-confidence detections are biased toward asking the user rather than silently denying, because most bare tokens with file extensions are not actually sensitive paths -- false denials break the agent's workflow and are noticed immediately, while false allows may not be. + +### L3: Static wildcards in commands + +The agent uses shell glob patterns in a command that could expand to match sensitive files. + +> `cat .env*`, `grep SECRET *.toml`, `find . -name '*.pem'` + +Fermata handles this by checking whether the glob pattern in the command **overlaps** with any configured policy rule. For example, `.env*` in a command is checked against `.botignore` patterns -- if `.env` is protected, `.env*` is flagged. This does not require enumerating the filesystem; it is a pattern-vs-pattern overlap check. + +The limitation: fermata can tell whether a glob *could* hit a protected file, but not whether it *actually will* in the current directory state. For the heuristic threat model, "could hit" is the right bar. + +### Secret redaction (PostToolUse) + +Independent of path-level detection, fermata filters tool output before it enters the LLM context: + +- **Known-value redaction** -- `.botsecrets` declares which files contain secrets. Fermata parses those files, extracts the secret values, and replaces them in all tool output using an Aho-Corasick automaton. Sub-millisecond performance. Zero false negatives for declared secrets. +- **Heuristic scanning** -- regex patterns (derived from gitleaks) detect undeclared secrets in tool output: AWS access keys, JWTs, GitHub PATs, database connection strings, and similar high-entropy tokens. This is a safety net for secrets not covered by the manifest. + +Together these mean that even if a path-level check misses a read (the agent reaches `.env` through a route fermata didn't parse), the secret values themselves are scrubbed from the output before the LLM sees them. + +--- + +## What fermata does NOT catch + +These are honest boundaries, not future promises. Documenting them is part of the value -- it tells you where you still need other defences. + +### L4: Shell indirection + +Variable expansion (`cat $SECRET_FILE`), command substitution (`cat $(ls .env*)`), tool composition (`xargs cat < file_list`), and pipelines that route content through intermediate steps. + +A real `bash` parser would handle the syntax, but cannot resolve runtime values. Fermata sees the command string, not the expanded result. It can detect the presence of substitution or variable-dereference syntax and escalate to an ask, but it cannot statically evaluate what the command will actually do. + +**Why this is acceptable for the threat model:** LLM agents overwhelmingly prefer direct, readable commands. Shell indirection is rare in practice -- agents reach for `cat /path/to/file`, not `cat $(echo /path/to/file)`. The statistical frequency of L4 evasion in non-adversarial agent behaviour is low. + +### L5: Derived or out-of-band names + +The agent computes a path from sources fermata never sees: its own reasoning, an HTTP response, a previous tool's stdout that the harness reused as input. + +> The agent reads a config file, learns about `/etc/app/signing.key`, then reads it in a subsequent call. + +**Important:** most derived-name scenarios collapse back to L0 at the moment of the dangerous call. The second read is a direct path-typed tool call, and `.botignore` catches it normally. Fermata does not need to predict the derivation; it just needs the rule to match the resolved path when the call happens. + +The genuinely difficult sub-case is when a derived name is used inside L4-style shell indirection -- which is no harder than L4 already is. + +### L6: Application-protocol exfiltration -- out of scope by design + +The agent uses a legitimate tool (HTTP client, git push, shell with network access) to move forbidden content to a destination fermata cannot inspect. The content travels as the *payload* of an allowed call, not as a path. + +> The agent reads a secret (caught and redacted at L0), but then reconstructs it character-by-character and sends it via an HTTP request. + +This is the network-firewall analogy: **you cannot block exfiltration over an allowed application protocol from outside the application.** A packet filter cannot inspect encrypted traffic without MITM; a static policy gate cannot inspect the semantic intent of an allowed tool's payload. Fermata marks this as permanently out of scope. + +This is not a failure -- it is a design boundary. Trying to solve L6 within a static policy gate would require either breaking the tool's legitimate use (blocking all network access) or implementing deep content inspection that defeats the latency and side-effect-free constraints that make fermata practical. + +--- + +## Coverage summary + +| Level | Description | Detection | Confidence | +|-------|-------------|-----------|------------| +| L0 | Direct path argument | Full policy check | High | +| L1 | Absolute path in Bash | Path extraction + policy | High | +| L2 | Bare filename in CWD | CWD resolution + policy | Medium | +| L3 | Wildcards in commands | Pattern overlap check | Medium | +| L4 | Shell indirection | Syntax detection only | Low | +| L5 | Derived names | Collapses to L0 at call time | Depends on final call | +| L6 | Protocol exfiltration | Out of scope | N/A | + +Secret redaction (PostToolUse) operates as a separate, independent layer. Even when path-level detection at L1-L4 misses a read, known-value redaction catches the secret values in the output. + +--- + +## Practical implications + +Fermata is one layer in a defence-in-depth stack. Here is what it is good at and what to pair it with. + +### Fermata alone gives you + +- **Accidental secret exposure prevention.** The most common case: agent reads `.env`, `secrets.toml`, or a credential file because it matched a directory listing. Fermata blocks this at L0 with zero configuration beyond `.botignore`. +- **Known-secret scrubbing.** Even if a secret leaks through an indirect read, declared secrets are redacted from LLM context. The agent never "learns" the value. +- **Heuristic secret detection.** Undeclared secrets matching common formats (AWS keys, tokens, connection strings) are flagged in tool output. +- **Command guardrails.** Dangerous shell patterns (`rm -rf /`, `curl | sh`) are caught by configurable deny lists. + +### Combine fermata with + +- **Network-level controls** for L6 scenarios. If your threat model includes data exfiltration by a compromised or misbehaving agent, restrict outbound network access at the OS or container level. Fermata cannot inspect what an HTTP client sends. +- **Sandboxing / containerisation** for hard isolation. Fermata is a policy gate, not a sandbox. If you need filesystem isolation guarantees (not just heuristic blocking), run the agent in a container with a restricted mount. +- **Secret rotation** as a backstop. If a secret does leak into LLM context through an uncovered path, rotating the secret limits the blast radius. Fermata's redaction makes this unlikely for declared secrets, but rotation is good practice regardless. +- **Audit logging** for visibility. Fermata's decisions can be logged for review. Pairing with an audit trail lets you detect patterns that individual checks might miss. + +### Design constraints worth knowing + +- **Latency budget.** Fermata fires on every tool call and targets single-digit milliseconds. Anything requiring filesystem enumeration or network calls at check time is either opt-in or out of scope. +- **Side-effect-free.** Fermata does not open, move, stat, or modify files outside of its policy resolution. It operates purely on the information the harness provides. +- **False deny vs false allow.** A false deny breaks the agent's workflow and is noticed immediately. A false allow may go undetected. Low-confidence detections bias toward asking the user, not silently denying.