🏗️ fermata: redaction-first security model, unified .botsecrets config

Realign fermata around redaction (PostToolUse) as the primary security
layer, with access control (PreToolUse) as supplementary write/bash
protection. Remove botignore.toml — policy rules now live in .botsecrets
[policy] section. Add fermata.toml as an alias for .botsecrets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 01:10:07 +02:00
parent 77520819f6
commit 168aefd415
17 changed files with 571 additions and 423 deletions
+5 -5
View File
@@ -1,20 +1,20 @@
# Package: dirigent_fermata
Harness-agnostic policy gate and secret filtering engine for AI coding agents.
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 — policy gate + secret filtering engine
- **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, `botignore.toml` parser, `Policy::check` / `check_command`, path extraction. Sync, no tokio.
- **`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 and hierarchical resolution (user, project, local override).
- `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.
@@ -40,4 +40,4 @@ Codex / Gemini hook adapters, MCP server mode, `readonly_only` Bash mode, audit
- `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`the `.gitignore` of AI agent secret protection
- `.botsecrets` format: `core/secrets/config.rs`unified config for secret redaction and policy (`fermata.toml` accepted as alias)
+1 -1
View File
@@ -3,7 +3,7 @@ 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)"
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"
+51 -51
View File
@@ -2,18 +2,20 @@
**A fast, harness-agnostic 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. No AI coding agent ships built-in post-read secret filtering today. fermata fixes that.
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.
## Why
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.
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.
fermata operates on two independent levels:
- **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.
- **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 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.
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.
> **Note:** fermata also accepts `fermata.toml` as an alias for `.botsecrets` (same format, `.botsecrets` takes priority when both exist).
## Quick Start
@@ -26,16 +28,21 @@ cargo install --path . --features cli
### Protect a project in 30 seconds
```bash
# Block direct access to secret files
echo ".env" > .botignore
# Declare where secrets live -- fermata parses them and redacts values
# Declare where secrets live -- fermata parses them and redacts values from agent output
cat > .botsecrets << 'EOF'
[files]
patterns = [".env", ".env.*", "secrets.*"]
[policy.write]
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]`.
### Wire into Claude Code
Add both hooks in `.claude/settings.json`:
@@ -63,7 +70,7 @@ Add both hooks in `.claude/settings.json`:
}
```
That's it. PreToolUse blocks forbidden operations. PostToolUse redacts secret values from tool output before they reach the LLM.
That's it. PostToolUse redacts secret values from tool output before they reach the LLM. PreToolUse blocks forbidden writes and dangerous commands.
## How It Works
@@ -72,13 +79,15 @@ fermata interposes on every tool call in the agent's lifecycle:
```
Agent wants to run a tool
|
PreToolUse ── fermata checks .botignore / botignore.toml
| blocked? → deny with reason
| allowed? ↓
PreToolUse ── .botsecrets [policy] / .botignore
| write blocked? → deny
| bash denied? → deny
| otherwise → allow (including reads of .env!)
|
Tool executes
|
PostToolUse ── fermata scans output for secret values
| found? → replace with ***** before LLM sees it
PostToolUse ── .botsecrets [files] + [keys] + [heuristic]
| secret values found? → redact before LLM sees it
|
Clean output enters LLM context
```
@@ -87,44 +96,17 @@ Three layers of defense, each independent:
| 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 |
| **Access control** | `.botsecrets [policy]` / `.botignore` rules block writes and dangerous commands | Destructive writes, anti-jailbreak (agent modifying its own hooks), dangerous shell commands |
Performance: ~1-5ms per tool call. Cold start (loading config + parsing secret files) is ~10-20ms.
## Configuration
Three files, each optional, each solving a different problem:
### `.botsecrets` -- the primary (and usually only) config
### `.botignore` -- the 80% case
Gitignore syntax. Blocks both reads and writes. Onboarding is one line.
```gitignore
.env
.env.*
secrets/**
```
### `botignore.toml` -- per-operation rules
Separate namespaces so the same file can be readable but not writable:
```toml
[read]
patterns = [".env*", "secrets/**"]
[write]
patterns = ["vendor/**", "*.lock"]
[bash]
deny = ["rm -rf /", "curl * | sh"]
```
### `.botsecrets` -- secret value redaction
Declares which files contain secrets. fermata parses them, extracts values, and redacts every occurrence in tool output.
`.botsecrets` is the unified configuration file. It declares both what to redact and what to restrict:
```toml
[files]
@@ -135,10 +117,28 @@ include = ["STRIPE_*", "MY_APP_SIGNING_*"]
[heuristic]
enabled = true
# Access control: write protection and bash safety.
# Reading secret-containing files is allowed -- Layer 1 redacts the values.
[policy.write]
patterns = [".claude/**", "vendor/**", "*.lock"]
[policy.bash]
deny = ["rm -rf /", "curl * | sh"]
```
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
@@ -202,20 +202,20 @@ The policy engine and redaction logic are identical across all modes. Only the I
## Status
v0.2 -- policy gate and secret filtering engine are production-ready. All core components are implemented and tested:
v0.2 -- secret filtering engine and policy gate 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)
- `.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
## 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.
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.
## License
+24 -6
View File
@@ -6,7 +6,7 @@ Fermata is a policy gate and secret filtering engine for AI coding agents. It sh
## 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.
Check whether one or more paths are allowed for a given operation. Fermata locates the nearest project root (a directory containing `.botsecrets`, `.botignore`, or `.git`) and evaluates the policy defined there.
**Usage**
@@ -79,7 +79,7 @@ Fermata follows a **fail-open** policy for hooks: if the payload cannot be parse
**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.
- **pre-tool-use** -- Runs before the tool executes. Fermata checks the requested path or command against `.botsecrets [policy]` / `.botignore` 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**
@@ -102,9 +102,8 @@ Fermata does not have its own global config file. All configuration lives in you
| File | Purpose |
|------|---------|
| `.botsecrets` | TOML file for secret redaction (`[files]`, `[heuristic]`, `[keys]`) **and** access-control policy (`[policy]` section with `[policy.read]`, `[policy.write]`, `[policy.bash]`). Unified config for most projects. `fermata.toml` is accepted as an alias (same format, `.botsecrets` takes priority). |
| `.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.
@@ -135,6 +134,25 @@ enabled = true
The `.botignore` blocks direct reads. The `.botsecrets` catches secrets that leak through indirect paths (shell output, log files, error messages).
### Single-file setup with .botsecrets
You can consolidate both secret redaction and access policy into `.botsecrets` alone, replacing the need for a separate `.botignore`:
```toml
# .botsecrets
[files]
patterns = [".env", ".env.*"]
[heuristic]
enabled = true
[policy.read]
deny = [".env", ".env.*", "secrets/**", "credentials/**"]
[policy.bash]
deny = ["rm -rf /", "curl * | sh"]
```
### Wire fermata into Claude Code
Add both hook events to `.claude/settings.json`:
@@ -164,10 +182,10 @@ Add both hook events to `.claude/settings.json`:
### Block dangerous shell commands
Use `botignore.toml` to deny specific command patterns:
Use `.botsecrets [policy.bash]` to deny specific command patterns:
```toml
[bash]
[policy.bash]
deny = ["rm -rf /", "curl * | sh", "wget * | bash"]
```
+158 -214
View File
@@ -1,165 +1,29 @@
# 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.
Fermata uses up to two configuration files to control what an AI coding agent
can access and what secret values it can see. `.botsecrets` is the primary
configuration file. Most projects need only this file.
| 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 |
| `.botsecrets` | Declare secrets, control redaction, and set policy rules | TOML |
| `.botignore` | Block agent access to matching paths (optional, gitignore syntax) | gitignore |
All three files are optional. Without any configuration Fermata allows all
operations and performs no redaction. Add only the files you need.
`fermata.toml` is accepted as an alias for `.botsecrets` (same format, `.botsecrets` takes priority when both exist).
Configuration is layered, with later (more specific) sources overriding earlier
ones. All files are optional. Without any configuration Fermata allows all
operations and performs no redaction.
---
## `.botignore` -- Path-Based Access Control
## `.botsecrets` -- Secret Redaction and Policy
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.).
`.botsecrets` declares which files contain secrets, how Fermata should
redact them from tool output, and (optionally) access control policy rules.
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
@@ -341,35 +205,126 @@ path = ".env.production"
format = "env"
```
### `[policy]` -- Access Control Rules
Optional access control rules embedded directly in `.botsecrets`.
#### `[policy.write]` -- Write Protection
Blocks the agent from writing to matching paths. This is the primary use case
for access control -- protecting vendored code, lock files, and policy files
from modification.
| Field | Type | Description |
|-------|------|-------------|
| `patterns` | `string[]` | Glob patterns for paths the agent cannot write to. |
```toml
[policy.write]
patterns = [".claude/**", "vendor/**", "*.lock", "dist/**"]
```
#### `[policy.bash]` -- Command Execution Rules
Controls which shell commands the agent can run.
| Field | Type | Description |
|-------|------|-------------|
| `deny` | `string[]` | Patterns that block commands outright. |
| `allow_prefixes` | `string[]` | Command prefixes always allowed. |
| `ask` | `string[]` | Patterns requiring user confirmation. |
```toml
[policy.bash]
deny = ["rm -rf /", "curl * | sh"]
allow_prefixes = ["cargo", "npm", "just"]
ask = ["docker", "kubectl"]
```
#### `[policy.read]` -- Read Restrictions (Rarely Needed)
Blocks reads of matching paths. Rarely needed -- secret values are redacted by
the PostToolUse layer regardless of whether the read is allowed. Use this only
for files the agent cannot usefully read (e.g., binary blobs, large data files).
```toml
[policy.read]
patterns = ["*.sqlite", "*.dat"]
```
---
## `.botignore` -- Path-Based Access Control
For most projects, `.botsecrets` with `[policy]` is sufficient. `.botignore`
remains useful for monorepo subtree exclusion or teams that prefer gitignore
syntax for simple path blocking.
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
the `[policy]` section in `.botsecrets` 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 entire subtrees in a monorepo
packages/legacy-app/
vendor/
# Block private keys
*.pem
*.key
id_rsa
id_ed25519
# But allow the public key
!id_ed25519.pub
```
---
## Examples
### Minimal: Just Block Sensitive Files
### Minimal: Just Redact Secrets (most projects)
If you only need to prevent the agent from reading certain files, a single
`.botignore` is enough:
```gitignore
# .botignore
.env
.env.*
*.pem
*.key
```toml
# .botsecrets
[files]
patterns = [".env", ".env.*"]
```
### 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
```
### Standard: Redact Secrets + Write Protection
```toml
# .botsecrets
@@ -379,56 +334,34 @@ patterns = [".env", ".env.*", "secrets/*.yaml"]
[keys]
include = ["STRIPE_*"]
[redaction]
style = "masked"
[policy.write]
patterns = [".claude/**", "vendor/**", "*.lock"]
[policy.bash]
deny = ["rm -rf /", "curl * | sh"]
```
### Fine-Grained: Separate Read, Write, and Bash Rules
### With .botignore: Simple Path Blocking + Redaction
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:
For teams that prefer gitignore syntax:
```gitignore
# .botignore
.env
.env.*
*.pem
*.key
id_rsa
id_ed25519
# .botignore (optional)
node_modules/
build/
dist/
```
```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"]
# .botsecrets
[files]
patterns = [".env", ".env.*"]
```
### Full-Featured: Complete Configuration
A production setup using `.botsecrets` for both redaction and policy:
```toml
# .botsecrets
[files]
@@ -449,6 +382,17 @@ mode = "enforce"
mode = "strict"
on_parse_error = "mask-entire-file"
[policy.write]
patterns = [".claude/**", "vendor/**", "*.lock", "dist/**", "migrations/**"]
[policy.bash]
deny = ["rm -rf /", "curl * | sh"]
allow_prefixes = ["cargo", "npm", "just", "git"]
ask = ["docker", "terraform"]
[policy.read]
patterns = ["*.sqlite", "*.dat"]
[[file]]
path = "config/credentials.yaml"
format = "yaml"
+26 -17
View File
@@ -12,49 +12,49 @@ No AI coding agent ships built-in post-read secret filtering today. The entire i
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.
**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. In fermata's model, read access to secret-containing files is *allowed by default* -- the agent needs to see file structure and key names to reason about configuration.
**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.
These three dimensions are independent. An agent can have read access to `.env` without the secret values being revealed -- the output is redacted before it reaches the model. This is the default state, not a special configuration: 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.
fermata implements a layered security stack. Layers 1 and 2 are the primary defense -- they operate on data content (the actual secret values). Layers 3 and 4 are supplementary -- they operate on file and system identity.
### 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)
### Layer 1: 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.
This is the primary defense layer. It 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)
### Layer 2: 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.
Higher false-positive rate than Layer 1, but as a secondary safety net, that tradeoff is acceptable.
### Layer 3: Access Control (.botignore / .botsecrets [policy])
Block dangerous write operations and destructive commands. `Write .claude/settings.json` -- denied. `Bash: rm -rf /` -- denied. Uses gitignore-style patterns for write protection and command deny-lists for bash safety.
This layer is supplementary -- it protects against destructive writes and dangerous commands, not secret reads. Reading `.env` is allowed; the secret values never reach the model because Layer 1 redacts them. Write restrictions remain important for anti-jailbreak (preventing the agent from modifying its own hooks or policy files) and for protecting vendored or generated files.
**Limitation**: Cannot catch indirect access to secret values. An agent running `source .env && echo $DB_PASSWORD` bypasses file-level controls entirely. This is exactly why access control is supplementary and redaction (Layer 1) is primary.
### 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.
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 (1 and 2) that containment cannot address.
## Design Principles
@@ -66,7 +66,7 @@ fermata does not own this layer. It is provided by your container runtime, VM, o
**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.
**Single policy file.** `.botsecrets` is the unified configuration interface. One file declares both what to redact (`[files]`, `[keys]`, `[heuristic]`) and what to restrict (`[policy]`). How protection is delivered is a deployment concern, not a configuration concern.
## The .botsecrets Vision
@@ -89,6 +89,15 @@ include = ["STRIPE_*", "MY_APP_SIGNING_*"]
[heuristic]
enabled = true
# Access control: write protection and bash safety.
# Reading secret-containing files is allowed -- Layer 1 redacts the values.
[policy.write]
patterns = [".claude/**", "vendor/**", "*.lock"]
[policy.bash]
deny = ["rm -rf /", "curl * | sh"]
```
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.
+23 -16
View File
@@ -16,19 +16,34 @@ This shifts the design centre from "what can a smart attacker hide?" to "what do
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.
Fermata's primary defence is not path blocking but **value redaction**. Even when a path-level check misses a read (L4 shell indirection, L5 derived names), known secret values are scrubbed from tool output before the LLM sees them. The path-level checks (L0--L3) provide supplementary protection -- they prevent unnecessary file access and dangerous commands, but they are not the last line of defence against secret leakage.
---
## 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.
Fermata's detection operates on two independent tracks: **value-level redaction** (primary) and **path-level access control** (supplementary).
### Secret redaction (PostToolUse) -- primary defence
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.
This is the layer that makes fermata's security model resilient. Even if every path-level check fails -- 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.
### Path-level access control (PreToolUse) -- supplementary
The path-level checks below are organised by how the agent names the path it is about to access. Lower levels are easier to detect statically; higher levels require progressively more runtime information. These checks prevent unnecessary file access and reduce noise, but they are not the last line of defence.
### 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.
The agent calls a path-typed tool (Read, Write, Edit) with an explicit file path.
> `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.
Both `.botignore` (gitignore semantics, deepest directory wins, negation patterns work as in git) and `.botsecrets [policy]` rules 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
@@ -56,15 +71,6 @@ Fermata handles this by checking whether the glob pattern in the command **overl
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
@@ -105,6 +111,7 @@ This is not a failure -- it is a design boundary. Trying to solve L6 within a st
| Level | Description | Detection | Confidence |
|-------|-------------|-----------|------------|
| Secret redaction | Known-value + heuristic scanning | Aho-Corasick + regex | High (zero false negatives for declared secrets) |
| 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 |
@@ -113,7 +120,7 @@ This is not a failure -- it is a design boundary. Trying to solve L6 within a st
| 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.
Secret redaction is the primary defence layer. Path-level detection (L0--L3) provides supplementary access control; even when it misses, redaction catches the secret values in the output.
---
@@ -123,10 +130,10 @@ Fermata is one layer in a defence-in-depth stack. Here is what it is good at and
### 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.
- **Known-secret scrubbing.** Declared secrets (via `.botsecrets`) are redacted from all tool output, always. The agent never "learns" the value, regardless of how it reached the file.
- **Heuristic secret detection.** Undeclared secrets matching common formats (AWS keys, tokens, connection strings) are flagged in tool output. Built-in patterns handle most projects without any configuration.
- **Command guardrails.** Dangerous shell patterns (`rm -rf /`, `curl | sh`) are caught by configurable deny lists.
- **Write protection and access control.** Path-level checks (`.botignore`, `.botsecrets [policy]`) prevent unnecessary file access and unauthorised modifications at L0--L3.
### Combine fermata with
+21 -17
View File
@@ -1,7 +1,7 @@
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 crate::core::secrets::config::{PolicyConfig, SecretsConfig};
use std::path::{Path, PathBuf};
use thiserror::Error;
@@ -9,16 +9,14 @@ use thiserror::Error;
pub enum PolicyError {
#[error(transparent)]
Botignore(#[from] BotignoreError),
#[error(transparent)]
Toml(#[from] TomlConfigError),
#[error("invalid pattern in botignore.toml: {0}")]
#[error("invalid pattern in .botsecrets: {0}")]
BadPattern(String),
}
pub struct Policy {
root: PathBuf,
botignore: BotignoreSet,
toml: BotignoreToml,
policy: PolicyConfig,
read_globs: globset::GlobSet,
write_globs: globset::GlobSet,
read_patterns: Vec<String>,
@@ -28,19 +26,25 @@ pub struct Policy {
impl Policy {
pub fn load(root: &Path) -> Result<Self, PolicyError> {
let botignore = BotignoreSet::load(root)?;
let toml = BotignoreToml::load(root)?;
// Load policy from .botsecrets (or fermata.toml fallback).
// If SecretsConfig::load() fails, use empty defaults (fail-open).
let policy = SecretsConfig::load(root)
.ok()
.and_then(|s| s.policy)
.unwrap_or_default();
let (read_globs, read_patterns) = compile_globs(
toml.read.as_ref().map(|r| r.patterns.as_slice()).unwrap_or(&[]),
policy.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(&[]),
policy.write.as_ref().map(|r| r.patterns.as_slice()).unwrap_or(&[]),
)?;
Ok(Self {
root: root.to_path_buf(),
botignore,
toml,
policy,
read_globs,
write_globs,
read_patterns,
@@ -49,7 +53,7 @@ impl Policy {
}
pub fn check_command(&self, command: &str) -> Result<Decision, PolicyError> {
let bash = match self.toml.bash.as_ref() {
let bash = match self.policy.bash.as_ref() {
Some(b) => b,
None => return Ok(Decision::Allow),
};
@@ -57,9 +61,9 @@ impl Policy {
// 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),
message: format!("blocked by .botsecrets [policy.bash.deny]: {}", pat),
rule: Some(Rule {
source: self.root.join("botignore.toml"),
source: self.root.join(".botsecrets"),
pattern: pat,
}),
}));
@@ -75,9 +79,9 @@ impl Policy {
// 3. Ask patterns.
if let Some(pat) = match_command(command, &bash.ask)? {
return Ok(Decision::Ask(Reason {
message: format!("requires confirmation [bash.ask]: {}", pat),
message: format!("requires confirmation [policy.bash.ask]: {}", pat),
rule: Some(Rule {
source: self.root.join("botignore.toml"),
source: self.root.join(".botsecrets"),
pattern: pat,
}),
}));
@@ -97,7 +101,7 @@ impl Policy {
}
}
// 2. botignore.toml namespace-specific rules.
// 2. .botsecrets [policy] namespace-specific rules.
let (set, patterns) = match op {
Op::Read => (&self.read_globs, &self.read_patterns),
Op::Write => (&self.write_globs, &self.write_patterns),
@@ -109,9 +113,9 @@ impl Policy {
if let Some(idx) = matches.first() {
let pattern = patterns[*idx].clone();
return Ok(Decision::Deny(Reason {
message: format!("blocked by botignore.toml [{:?}]: {}", op, pattern),
message: format!("blocked by .botsecrets [policy.{:?}]: {}", op, pattern),
rule: Some(Rule {
source: self.root.join("botignore.toml"),
source: self.root.join(".botsecrets"),
pattern,
}),
}));
+3 -3
View File
@@ -1,11 +1,11 @@
use std::path::{Path, PathBuf};
/// Strong markers that definitively identify a project root.
const STRONG_MARKERS: &[&str] = &["botignore.toml", ".botignore.toml", ".botsecrets", ".git"];
const STRONG_MARKERS: &[&str] = &["fermata.toml", ".botsecrets", ".git"];
/// Walk upward from `target` (or its parent if `target` is a file) looking
/// for the nearest project root. Strong markers (`botignore.toml`,
/// `.botignore.toml`, `.git`) stop the walk immediately. A `.botignore`
/// for the nearest project root. Strong markers (`fermata.toml`,
/// `.botsecrets`, `.git`) stop the walk immediately. A `.botignore`
/// file is remembered as a fallback but does not stop the walk — the search
/// continues upward for a stronger boundary. If none is found, the
/// `.botignore` location is used.
+26 -1
View File
@@ -11,6 +11,7 @@
//! `keys.include` and `keys.exclude` *accumulate* across layers.
//! Scalar fields (style, mode, enabled) take the most-specific value.
use crate::core::toml_config::{BashRules, OpRules};
use globset::{Glob, GlobMatcher};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
@@ -38,6 +39,16 @@ pub enum SecretsConfigError {
// Config types
// ---------------------------------------------------------------------------
/// Optional `[policy]` section in `.botsecrets` (or `fermata.toml`).
///
/// When present, this provides file-access and bash policy rules.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct PolicyConfig {
pub read: Option<OpRules>,
pub write: Option<OpRules>,
pub bash: Option<BashRules>,
}
/// Top-level `.botsecrets` configuration.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SecretsConfig {
@@ -53,6 +64,8 @@ pub struct SecretsConfig {
pub enforcement: EnforcementConfig,
#[serde(default, rename = "file")]
pub file_overrides: Vec<FileOverride>,
#[serde(default)]
pub policy: Option<PolicyConfig>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@@ -252,6 +265,7 @@ impl Default for SecretsConfig {
heuristic: HeuristicConfig::default(),
enforcement: EnforcementConfig::default(),
file_overrides: Vec::new(),
policy: None,
}
}
}
@@ -320,6 +334,8 @@ struct PartialSecretsConfig {
enforcement: Option<PartialEnforcementConfig>,
#[serde(default, rename = "file")]
file: Option<Vec<FileOverride>>,
#[serde(default)]
policy: Option<PolicyConfig>,
}
#[derive(Debug, Clone, Default, Deserialize)]
@@ -414,6 +430,11 @@ impl SecretsConfig {
if let Some(overrides) = layer.file {
self.file_overrides = overrides;
}
// policy (most-specific layer wins entirely)
if layer.policy.is_some() {
self.policy = layer.policy;
}
}
}
@@ -454,11 +475,15 @@ impl SecretsConfig {
}
}
// Layer 3: project root
// Layer 3: project root (.botsecrets takes priority over fermata.toml)
let project_file = root.join(".botsecrets");
let fermata_file = root.join("fermata.toml");
if project_file.is_file() {
let layer = Self::read_partial(&project_file)?;
config.merge_layer(layer);
} else if fermata_file.is_file() {
let layer = Self::read_partial(&fermata_file)?;
config.merge_layer(layer);
}
// Layer 4: local overrides
-30
View File
@@ -1,14 +1,4 @@
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 {
@@ -25,23 +15,3 @@ pub struct BashRules {
#[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)
}
}
+182
View File
@@ -0,0 +1,182 @@
//! Tests for `.botsecrets [policy]` section integration with `Policy`,
//! including `fermata.toml` alias support.
use dirigent_fermata::core::{Decision, Op, Policy};
use std::fs;
use tempfile::TempDir;
/// Helper: create a temp project dir with optional `.botignore` and `.botsecrets` files.
fn make_project(botignore: &str, botsecrets: &str) -> TempDir {
let tmp = TempDir::new().unwrap();
if !botignore.is_empty() {
fs::write(tmp.path().join(".botignore"), botignore).unwrap();
}
if !botsecrets.is_empty() {
fs::write(tmp.path().join(".botsecrets"), botsecrets).unwrap();
}
tmp
}
#[test]
fn botsecrets_policy_write_works() {
let tmp = make_project(
"",
r#"
[policy.write]
patterns = ["vendor/**"]
"#,
);
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();
// Write to vendor/** should be denied via .botsecrets [policy.write]
assert!(matches!(
policy.check(Op::Write, &target).unwrap(),
Decision::Deny(_)
));
// Read should still be allowed (no read rules)
assert_eq!(policy.check(Op::Read, &target).unwrap(), Decision::Allow);
}
#[test]
fn botsecrets_policy_read_works() {
let tmp = make_project(
"",
r#"
[policy.read]
patterns = ["secrets/**"]
"#,
);
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 botsecrets_without_policy_section_does_not_affect_policy() {
let tmp = make_project(
"",
r#"
[keys]
include = ["MY_CUSTOM_KEY"]
"#,
);
let policy = Policy::load(tmp.path()).unwrap();
// Everything should be allowed — no policy rules
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_eq!(policy.check(Op::Write, &target).unwrap(), Decision::Allow);
}
#[test]
fn botsecrets_policy_bash_deny_works() {
let tmp = make_project(
"",
r#"
[policy.bash]
deny = ["rm -rf /"]
ask = ["git push"]
"#,
);
let policy = Policy::load(tmp.path()).unwrap();
let d = policy.check_command("rm -rf /").unwrap();
assert!(matches!(d, Decision::Deny(_)));
let d = policy.check_command("git push origin main").unwrap();
assert!(matches!(d, Decision::Ask(_)));
let d = policy.check_command("cargo build").unwrap();
assert_eq!(d, Decision::Allow);
}
#[test]
fn malformed_botsecrets_falls_back_to_defaults() {
// .botsecrets is malformed — should still load with empty policy (fail-open)
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join(".botsecrets"), "this is not valid toml [[[").unwrap();
let policy = Policy::load(tmp.path()).unwrap();
let target = tmp.path().join("anything.rs");
fs::write(&target, "").unwrap();
assert_eq!(policy.check(Op::Read, &target).unwrap(), Decision::Allow);
}
#[test]
fn fermata_toml_works_as_alias() {
// No .botsecrets, but fermata.toml exists — should be loaded
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("fermata.toml"),
r#"
[policy.write]
patterns = ["generated/**"]
"#,
)
.unwrap();
let policy = Policy::load(tmp.path()).unwrap();
let target = tmp.path().join("generated/output.rs");
fs::create_dir_all(target.parent().unwrap()).unwrap();
fs::write(&target, "").unwrap();
assert!(matches!(
policy.check(Op::Write, &target).unwrap(),
Decision::Deny(_)
));
}
#[test]
fn botsecrets_takes_priority_over_fermata_toml() {
// Both .botsecrets and fermata.toml exist.
// .botsecrets blocks writes to vendor/**, fermata.toml blocks writes to src/**
// Only .botsecrets rules should apply.
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join(".botsecrets"),
r#"
[policy.write]
patterns = ["vendor/**"]
"#,
)
.unwrap();
fs::write(
tmp.path().join("fermata.toml"),
r#"
[policy.write]
patterns = ["src/**"]
"#,
)
.unwrap();
let policy = Policy::load(tmp.path()).unwrap();
// vendor/** should be blocked (from .botsecrets)
let vendor_target = tmp.path().join("vendor/lib.rs");
fs::create_dir_all(vendor_target.parent().unwrap()).unwrap();
fs::write(&vendor_target, "").unwrap();
assert!(matches!(
policy.check(Op::Write, &vendor_target).unwrap(),
Decision::Deny(_)
));
// src/** should NOT be blocked (fermata.toml was ignored because .botsecrets exists)
let src_target = tmp.path().join("src/main.rs");
fs::create_dir_all(src_target.parent().unwrap()).unwrap();
fs::write(&src_target, "").unwrap();
assert_eq!(
policy.check(Op::Write, &src_target).unwrap(),
Decision::Allow
);
}
+7 -7
View File
@@ -2,36 +2,36 @@ use dirigent_fermata::core::{Decision, Policy};
use std::fs;
use tempfile::TempDir;
fn project_with(toml: &str) -> TempDir {
fn project_with(botsecrets: &str) -> TempDir {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("botignore.toml"), toml).unwrap();
fs::write(tmp.path().join(".botsecrets"), botsecrets).unwrap();
tmp
}
#[test]
fn deny_substring_blocks() {
let tmp = project_with("[bash]\ndeny = [\"rm -rf /\"]\n");
let tmp = project_with("[policy.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 tmp = project_with("[policy.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 tmp = project_with("[policy.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 tmp = project_with("[policy.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);
@@ -46,7 +46,7 @@ fn no_rules_means_allow() {
#[test]
fn deny_takes_precedence_over_allow_prefix() {
let tmp = project_with("[bash]\ndeny = [\"rm -rf /\"]\nallow_prefixes = [\"rm\"]\n");
let tmp = project_with("[policy.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(_)));
}
+9 -7
View File
@@ -2,11 +2,13 @@ use dirigent_fermata::core::{Decision, Op, Policy};
use std::fs;
use tempfile::TempDir;
fn make_project(botignore: &str, toml_text: &str) -> TempDir {
fn make_project(botignore: &str, botsecrets: &str) -> TempDir {
let tmp = TempDir::new().unwrap();
if !botignore.is_empty() {
fs::write(tmp.path().join(".botignore"), botignore).unwrap();
if !toml_text.is_empty() {
fs::write(tmp.path().join("botignore.toml"), toml_text).unwrap();
}
if !botsecrets.is_empty() {
fs::write(tmp.path().join(".botsecrets"), botsecrets).unwrap();
}
tmp
}
@@ -42,8 +44,8 @@ fn unmatched_path_allowed() {
}
#[test]
fn toml_read_block_applies_only_to_read() {
let tmp = make_project("", "[read]\npatterns = [\"secrets/**\"]\n");
fn policy_read_block_applies_only_to_read() {
let tmp = make_project("", "[policy.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();
@@ -53,8 +55,8 @@ fn toml_read_block_applies_only_to_read() {
}
#[test]
fn toml_write_block_applies_only_to_write() {
let tmp = make_project("", "[write]\npatterns = [\"vendor/**\"]\n");
fn policy_write_block_applies_only_to_write() {
let tmp = make_project("", "[policy.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();
+5 -6
View File
@@ -3,12 +3,11 @@ use std::fs;
use tempfile::TempDir;
#[test]
fn finds_botignore_toml_first() {
fn finds_fermata_toml() {
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.toml"), "").unwrap();
fs::write(root.join("fermata.toml"), "").unwrap();
fs::create_dir_all(root.join(".git")).unwrap();
let target = root.join("sub/deep/file.rs");
@@ -19,11 +18,11 @@ fn finds_botignore_toml_first() {
}
#[test]
fn finds_dot_botignore_toml() {
fn finds_botsecrets() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join("sub")).unwrap();
fs::write(root.join(".botignore.toml"), "").unwrap();
fs::write(root.join(".botsecrets"), "").unwrap();
let target = root.join("sub/file.rs");
fs::write(&target, "").unwrap();
@@ -110,7 +109,7 @@ 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.toml"), "").unwrap();
fs::write(root.join("a/fermata.toml"), "").unwrap();
let target = root.join("a/b/c/file.rs");
fs::write(&target, "").unwrap();
+24 -36
View File
@@ -1,47 +1,35 @@
use dirigent_fermata::core::toml_config::{BotignoreToml, OpRules, BashRules};
use dirigent_fermata::core::toml_config::{OpRules, BashRules};
#[test]
fn parses_full_config() {
fn op_rules_deserialize() {
let src = r#"patterns = [".env*", "secrets/**"]"#;
let rules: OpRules = toml::from_str(src).unwrap();
assert_eq!(rules.patterns, vec![".env*", "secrets/**"]);
}
#[test]
fn op_rules_default_is_empty() {
let rules = OpRules::default();
assert!(rules.patterns.is_empty());
}
#[test]
fn bash_rules_deserialize() {
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:*"]);
let rules: BashRules = toml::from_str(src).unwrap();
assert_eq!(rules.deny, vec!["rm -rf /", "git push --force*"]);
assert_eq!(rules.ask, vec!["rm:*"]);
assert_eq!(rules.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());
fn bash_rules_default_is_empty() {
let rules = BashRules::default();
assert!(rules.deny.is_empty());
assert!(rules.ask.is_empty());
assert!(rules.allow_prefixes.is_empty());
}
+5 -5
View File
@@ -9,9 +9,9 @@ fn fixture() -> TempDir {
let root = tmp.path();
fs::write(root.join(".botignore"), ".env\n.env.*\nconf/cert/**\nconf/mitmproxy/**\n").unwrap();
fs::write(
root.join("botignore.toml"),
root.join(".botsecrets"),
r#"
[read]
[policy.read]
patterns = [
"conf/localtestsettings.yaml",
"conf/localsettings.yaml",
@@ -19,14 +19,14 @@ patterns = [
".claude/self-reflections/**",
]
[write]
[policy.write]
patterns = [
"conf/localtestsettings.yaml",
"conf/localsettings.yaml",
"conf/default-secrets.yaml",
]
[bash]
[policy.bash]
deny = ["localtestsettings.yaml", "localsettings.yaml", "default-secrets.yaml", ".env"]
ask = ["rm *", "mv *"]
allow_prefixes = ["make test"]
@@ -103,7 +103,7 @@ fn bash_rm_somefile_asks() {
#[test]
fn read_self_reflections_asks() {
// Note: A.4 has self-reflections under "ask" — current toml schema uses `[read].patterns`
// Note: A.4 has self-reflections under "ask" — current toml schema uses `[policy.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();