🏗️ 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:
@@ -1,20 +1,20 @@
|
|||||||
# Package: dirigent_fermata
|
# 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
|
## Quick Facts
|
||||||
- **Type**: Library + binary (`fermata`)
|
- **Type**: Library + binary (`fermata`)
|
||||||
- **Main Entry**: `src/lib.rs`, `src/bin/fermata.rs`
|
- **Main Entry**: `src/lib.rs`, `src/bin/fermata.rs`
|
||||||
- **Dependencies**: `ignore`, `toml`, `regex`, `globset`, `serde`, `clap` (cli feature), `aho-corasick`, `serde_yaml`
|
- **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
|
## Layering
|
||||||
|
|
||||||
Three concentric layers; nothing inner imports from anything outer.
|
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:
|
- **`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.
|
- `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.
|
- `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.
|
- `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/tools/fermata.md` — Dirigent integration plan
|
||||||
- `docs/workpad/brainstorm/fermata.md` — canonical product spec
|
- `docs/workpad/brainstorm/fermata.md` — canonical product spec
|
||||||
- `docs/architecture/fermata-security-philosophy.md` — security philosophy and the reveal triangle
|
- `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
@@ -3,7 +3,7 @@ name = "dirigent_fermata"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.75"
|
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"
|
license = "MIT OR Apache-2.0"
|
||||||
repository = "https://git.g4b.org/dirigence/fermata"
|
repository = "https://git.g4b.org/dirigence/fermata"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|||||||
@@ -2,18 +2,20 @@
|
|||||||
|
|
||||||
**A fast, harness-agnostic security layer for AI coding agents.**
|
**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
|
## 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:
|
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` 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.
|
||||||
- **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.
|
- **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
|
## Quick Start
|
||||||
|
|
||||||
@@ -26,16 +28,21 @@ cargo install --path . --features cli
|
|||||||
### Protect a project in 30 seconds
|
### Protect a project in 30 seconds
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Block direct access to secret files
|
# Declare where secrets live -- fermata parses them and redacts values from agent output
|
||||||
echo ".env" > .botignore
|
|
||||||
|
|
||||||
# Declare where secrets live -- fermata parses them and redacts values
|
|
||||||
cat > .botsecrets << 'EOF'
|
cat > .botsecrets << 'EOF'
|
||||||
[files]
|
[files]
|
||||||
patterns = [".env", ".env.*", "secrets.*"]
|
patterns = [".env", ".env.*", "secrets.*"]
|
||||||
|
|
||||||
|
[policy.write]
|
||||||
|
patterns = [".claude/**", "vendor/**", "*.lock"]
|
||||||
|
|
||||||
|
[policy.bash]
|
||||||
|
deny = ["rm -rf /", "curl * | sh"]
|
||||||
EOF
|
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
|
### Wire into Claude Code
|
||||||
|
|
||||||
Add both hooks in `.claude/settings.json`:
|
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
|
## How It Works
|
||||||
|
|
||||||
@@ -72,13 +79,15 @@ fermata interposes on every tool call in the agent's lifecycle:
|
|||||||
```
|
```
|
||||||
Agent wants to run a tool
|
Agent wants to run a tool
|
||||||
|
|
|
|
||||||
PreToolUse ── fermata checks .botignore / botignore.toml
|
PreToolUse ── .botsecrets [policy] / .botignore
|
||||||
| blocked? → deny with reason
|
| write blocked? → deny
|
||||||
| allowed? ↓
|
| bash denied? → deny
|
||||||
|
| otherwise → allow (including reads of .env!)
|
||||||
|
|
|
||||||
Tool executes
|
Tool executes
|
||||||
|
|
|
|
||||||
PostToolUse ── fermata scans output for secret values
|
PostToolUse ── .botsecrets [files] + [keys] + [heuristic]
|
||||||
| found? → replace with ***** before LLM sees it
|
| secret values found? → redact before LLM sees it
|
||||||
|
|
|
|
||||||
Clean output enters LLM context
|
Clean output enters LLM context
|
||||||
```
|
```
|
||||||
@@ -87,44 +96,17 @@ Three layers of defense, each independent:
|
|||||||
|
|
||||||
| Layer | Mechanism | What it catches |
|
| 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 |
|
| **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 |
|
| **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.
|
Performance: ~1-5ms per tool call. Cold start (loading config + parsing secret files) is ~10-20ms.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Three files, each optional, each solving a different problem:
|
### `.botsecrets` -- the primary (and usually only) config
|
||||||
|
|
||||||
### `.botignore` -- the 80% case
|
`.botsecrets` is the unified configuration file. It declares both what to redact and what to restrict:
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[files]
|
[files]
|
||||||
@@ -135,10 +117,28 @@ include = ["STRIPE_*", "MY_APP_SIGNING_*"]
|
|||||||
|
|
||||||
[heuristic]
|
[heuristic]
|
||||||
enabled = true
|
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.
|
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.
|
See [docs/configuration.md](docs/configuration.md) for the full reference.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
@@ -202,20 +202,20 @@ The policy engine and redaction logic are identical across all modes. Only the I
|
|||||||
|
|
||||||
## Status
|
## 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
|
- `.botsecrets` config with `[files]`, `[keys]`, `[heuristic]`, and `[policy]` sections
|
||||||
- `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
|
- Aho-Corasick known-value redactor
|
||||||
- Heuristic scanner with gitleaks-derived patterns
|
- 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
|
## 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.
|
`.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
|
## License
|
||||||
|
|
||||||
|
|||||||
+24
-6
@@ -6,7 +6,7 @@ Fermata is a policy gate and secret filtering engine for AI coding agents. It sh
|
|||||||
|
|
||||||
## fermata check
|
## 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**
|
**Usage**
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ Fermata follows a **fail-open** policy for hooks: if the payload cannot be parse
|
|||||||
|
|
||||||
**Event types**
|
**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.
|
- **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**
|
**Examples**
|
||||||
@@ -102,9 +102,8 @@ Fermata does not have its own global config file. All configuration lives in you
|
|||||||
|
|
||||||
| File | Purpose |
|
| 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` | 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.
|
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).
|
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
|
### Wire fermata into Claude Code
|
||||||
|
|
||||||
Add both hook events to `.claude/settings.json`:
|
Add both hook events to `.claude/settings.json`:
|
||||||
@@ -164,10 +182,10 @@ Add both hook events to `.claude/settings.json`:
|
|||||||
|
|
||||||
### Block dangerous shell commands
|
### Block dangerous shell commands
|
||||||
|
|
||||||
Use `botignore.toml` to deny specific command patterns:
|
Use `.botsecrets [policy.bash]` to deny specific command patterns:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[bash]
|
[policy.bash]
|
||||||
deny = ["rm -rf /", "curl * | sh", "wget * | bash"]
|
deny = ["rm -rf /", "curl * | sh", "wget * | bash"]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+158
-214
@@ -1,165 +1,29 @@
|
|||||||
# Configuration Reference
|
# Configuration Reference
|
||||||
|
|
||||||
Fermata uses three configuration files to control what an AI coding agent can
|
Fermata uses up to two configuration files to control what an AI coding agent
|
||||||
access and what secret values it can see. Configuration is layered, with later
|
can access and what secret values it can see. `.botsecrets` is the primary
|
||||||
(more specific) sources overriding earlier ones.
|
configuration file. Most projects need only this file.
|
||||||
|
|
||||||
| File | Purpose | Syntax |
|
| File | Purpose | Syntax |
|
||||||
|------|---------|--------|
|
|------|---------|--------|
|
||||||
| `.botignore` | Block agent access to matching paths (reads and writes) | gitignore |
|
| `.botsecrets` | Declare secrets, control redaction, and set policy rules | TOML |
|
||||||
| `botignore.toml` | Fine-grained per-operation rules (read, write, bash) | TOML |
|
| `.botignore` | Block agent access to matching paths (optional, gitignore syntax) | gitignore |
|
||||||
| `.botsecrets` | Declare secret-containing files and control redaction | TOML |
|
|
||||||
|
|
||||||
All three files are optional. Without any configuration Fermata allows all
|
`fermata.toml` is accepted as an alias for `.botsecrets` (same format, `.botsecrets` takes priority when both exist).
|
||||||
operations and performs no redaction. Add only the files you need.
|
|
||||||
|
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
|
`.botsecrets` declares which files contain secrets, how Fermata should
|
||||||
paths. When a path matches, both reads and writes are denied. There is no
|
redact them from tool output, and (optionally) access control policy rules.
|
||||||
distinction between operations -- if you need per-operation control, use
|
This prevents secret values from leaking into the LLM context window even
|
||||||
`botignore.toml` instead.
|
when the agent reads files indirectly (via shell output, log files, error
|
||||||
|
messages, etc.).
|
||||||
### 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
|
### Layered Configuration
|
||||||
|
|
||||||
@@ -341,35 +205,126 @@ path = ".env.production"
|
|||||||
format = "env"
|
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
|
## 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
|
```toml
|
||||||
`.botignore` is enough:
|
# .botsecrets
|
||||||
|
[files]
|
||||||
```gitignore
|
patterns = [".env", ".env.*"]
|
||||||
# .botignore
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
*.pem
|
|
||||||
*.key
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Standard: Block Access and Redact Secrets
|
### Standard: Redact Secrets + Write Protection
|
||||||
|
|
||||||
The most common setup. Block direct access with `.botignore` and redact leaked
|
|
||||||
values with `.botsecrets`:
|
|
||||||
|
|
||||||
```gitignore
|
|
||||||
# .botignore
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
secrets/**
|
|
||||||
*.pem
|
|
||||||
```
|
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
# .botsecrets
|
# .botsecrets
|
||||||
@@ -379,56 +334,34 @@ patterns = [".env", ".env.*", "secrets/*.yaml"]
|
|||||||
[keys]
|
[keys]
|
||||||
include = ["STRIPE_*"]
|
include = ["STRIPE_*"]
|
||||||
|
|
||||||
[redaction]
|
[policy.write]
|
||||||
style = "masked"
|
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:
|
For teams that prefer gitignore syntax:
|
||||||
|
|
||||||
```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
|
```gitignore
|
||||||
# .botignore
|
# .botignore (optional)
|
||||||
.env
|
node_modules/
|
||||||
.env.*
|
build/
|
||||||
*.pem
|
dist/
|
||||||
*.key
|
|
||||||
id_rsa
|
|
||||||
id_ed25519
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
# botignore.toml
|
# .botsecrets
|
||||||
[read]
|
[files]
|
||||||
patterns = ["secrets/**", ".aws/**"]
|
patterns = [".env", ".env.*"]
|
||||||
|
|
||||||
[write]
|
|
||||||
patterns = ["vendor/**", "*.lock", "dist/**", "migrations/**"]
|
|
||||||
|
|
||||||
[bash]
|
|
||||||
deny = ["rm -rf /", "curl * | sh"]
|
|
||||||
allow_prefixes = ["cargo", "npm", "just", "git"]
|
|
||||||
ask = ["docker", "terraform"]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Full-Featured: Complete Configuration
|
||||||
|
|
||||||
|
A production setup using `.botsecrets` for both redaction and policy:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
# .botsecrets
|
# .botsecrets
|
||||||
[files]
|
[files]
|
||||||
@@ -449,6 +382,17 @@ mode = "enforce"
|
|||||||
mode = "strict"
|
mode = "strict"
|
||||||
on_parse_error = "mask-entire-file"
|
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]]
|
[[file]]
|
||||||
path = "config/credentials.yaml"
|
path = "config/credentials.yaml"
|
||||||
format = "yaml"
|
format = "yaml"
|
||||||
|
|||||||
+26
-17
@@ -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.
|
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).
|
**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*.
|
**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.
|
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
|
## 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)
|
### Layer 1: Known-Value Redaction (.botsecrets + Aho-Corasick)
|
||||||
|
|
||||||
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.
|
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.
|
**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.
|
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.
|
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)
|
### 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.
|
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
|
## 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.
|
**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
|
## The .botsecrets Vision
|
||||||
|
|
||||||
@@ -89,6 +89,15 @@ include = ["STRIPE_*", "MY_APP_SIGNING_*"]
|
|||||||
|
|
||||||
[heuristic]
|
[heuristic]
|
||||||
enabled = true
|
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.
|
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
@@ -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.
|
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
|
## 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
|
### 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.
|
> `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
|
### 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.
|
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
|
## 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 |
|
| 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 |
|
| L0 | Direct path argument | Full policy check | High |
|
||||||
| L1 | Absolute path in Bash | Path extraction + policy | High |
|
| L1 | Absolute path in Bash | Path extraction + policy | High |
|
||||||
| L2 | Bare filename in CWD | CWD resolution + policy | Medium |
|
| 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 |
|
| L5 | Derived names | Collapses to L0 at call time | Depends on final call |
|
||||||
| L6 | Protocol exfiltration | Out of scope | N/A |
|
| 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
|
### 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.** Declared secrets (via `.botsecrets`) are redacted from all tool output, always. The agent never "learns" the value, regardless of how it reached the file.
|
||||||
- **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. Built-in patterns handle most projects without any configuration.
|
||||||
- **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.
|
- **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
|
### Combine fermata with
|
||||||
|
|
||||||
|
|||||||
+21
-17
@@ -1,7 +1,7 @@
|
|||||||
use crate::core::botignore::{BotignoreError, BotignoreSet};
|
use crate::core::botignore::{BotignoreError, BotignoreSet};
|
||||||
use crate::core::decision::{Decision, Reason, Rule};
|
use crate::core::decision::{Decision, Reason, Rule};
|
||||||
use crate::core::op::Op;
|
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 std::path::{Path, PathBuf};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
@@ -9,16 +9,14 @@ use thiserror::Error;
|
|||||||
pub enum PolicyError {
|
pub enum PolicyError {
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Botignore(#[from] BotignoreError),
|
Botignore(#[from] BotignoreError),
|
||||||
#[error(transparent)]
|
#[error("invalid pattern in .botsecrets: {0}")]
|
||||||
Toml(#[from] TomlConfigError),
|
|
||||||
#[error("invalid pattern in botignore.toml: {0}")]
|
|
||||||
BadPattern(String),
|
BadPattern(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Policy {
|
pub struct Policy {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
botignore: BotignoreSet,
|
botignore: BotignoreSet,
|
||||||
toml: BotignoreToml,
|
policy: PolicyConfig,
|
||||||
read_globs: globset::GlobSet,
|
read_globs: globset::GlobSet,
|
||||||
write_globs: globset::GlobSet,
|
write_globs: globset::GlobSet,
|
||||||
read_patterns: Vec<String>,
|
read_patterns: Vec<String>,
|
||||||
@@ -28,19 +26,25 @@ pub struct Policy {
|
|||||||
impl Policy {
|
impl Policy {
|
||||||
pub fn load(root: &Path) -> Result<Self, PolicyError> {
|
pub fn load(root: &Path) -> Result<Self, PolicyError> {
|
||||||
let botignore = BotignoreSet::load(root)?;
|
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(
|
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(
|
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 {
|
Ok(Self {
|
||||||
root: root.to_path_buf(),
|
root: root.to_path_buf(),
|
||||||
botignore,
|
botignore,
|
||||||
toml,
|
policy,
|
||||||
read_globs,
|
read_globs,
|
||||||
write_globs,
|
write_globs,
|
||||||
read_patterns,
|
read_patterns,
|
||||||
@@ -49,7 +53,7 @@ impl Policy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn check_command(&self, command: &str) -> Result<Decision, PolicyError> {
|
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,
|
Some(b) => b,
|
||||||
None => return Ok(Decision::Allow),
|
None => return Ok(Decision::Allow),
|
||||||
};
|
};
|
||||||
@@ -57,9 +61,9 @@ impl Policy {
|
|||||||
// 1. Deny wins over everything else.
|
// 1. Deny wins over everything else.
|
||||||
if let Some(pat) = match_command(command, &bash.deny)? {
|
if let Some(pat) = match_command(command, &bash.deny)? {
|
||||||
return Ok(Decision::Deny(Reason {
|
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 {
|
rule: Some(Rule {
|
||||||
source: self.root.join("botignore.toml"),
|
source: self.root.join(".botsecrets"),
|
||||||
pattern: pat,
|
pattern: pat,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
@@ -75,9 +79,9 @@ impl Policy {
|
|||||||
// 3. Ask patterns.
|
// 3. Ask patterns.
|
||||||
if let Some(pat) = match_command(command, &bash.ask)? {
|
if let Some(pat) = match_command(command, &bash.ask)? {
|
||||||
return Ok(Decision::Ask(Reason {
|
return Ok(Decision::Ask(Reason {
|
||||||
message: format!("requires confirmation [bash.ask]: {}", pat),
|
message: format!("requires confirmation [policy.bash.ask]: {}", pat),
|
||||||
rule: Some(Rule {
|
rule: Some(Rule {
|
||||||
source: self.root.join("botignore.toml"),
|
source: self.root.join(".botsecrets"),
|
||||||
pattern: pat,
|
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 {
|
let (set, patterns) = match op {
|
||||||
Op::Read => (&self.read_globs, &self.read_patterns),
|
Op::Read => (&self.read_globs, &self.read_patterns),
|
||||||
Op::Write => (&self.write_globs, &self.write_patterns),
|
Op::Write => (&self.write_globs, &self.write_patterns),
|
||||||
@@ -109,9 +113,9 @@ impl Policy {
|
|||||||
if let Some(idx) = matches.first() {
|
if let Some(idx) = matches.first() {
|
||||||
let pattern = patterns[*idx].clone();
|
let pattern = patterns[*idx].clone();
|
||||||
return Ok(Decision::Deny(Reason {
|
return Ok(Decision::Deny(Reason {
|
||||||
message: format!("blocked by botignore.toml [{:?}]: {}", op, pattern),
|
message: format!("blocked by .botsecrets [policy.{:?}]: {}", op, pattern),
|
||||||
rule: Some(Rule {
|
rule: Some(Rule {
|
||||||
source: self.root.join("botignore.toml"),
|
source: self.root.join(".botsecrets"),
|
||||||
pattern,
|
pattern,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
+3
-3
@@ -1,11 +1,11 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
/// Strong markers that definitively identify a project root.
|
/// 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
|
/// Walk upward from `target` (or its parent if `target` is a file) looking
|
||||||
/// for the nearest project root. Strong markers (`botignore.toml`,
|
/// for the nearest project root. Strong markers (`fermata.toml`,
|
||||||
/// `.botignore.toml`, `.git`) stop the walk immediately. A `.botignore`
|
/// `.botsecrets`, `.git`) stop the walk immediately. A `.botignore`
|
||||||
/// file is remembered as a fallback but does not stop the walk — the search
|
/// 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
|
/// continues upward for a stronger boundary. If none is found, the
|
||||||
/// `.botignore` location is used.
|
/// `.botignore` location is used.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
//! `keys.include` and `keys.exclude` *accumulate* across layers.
|
//! `keys.include` and `keys.exclude` *accumulate* across layers.
|
||||||
//! Scalar fields (style, mode, enabled) take the most-specific value.
|
//! Scalar fields (style, mode, enabled) take the most-specific value.
|
||||||
|
|
||||||
|
use crate::core::toml_config::{BashRules, OpRules};
|
||||||
use globset::{Glob, GlobMatcher};
|
use globset::{Glob, GlobMatcher};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -38,6 +39,16 @@ pub enum SecretsConfigError {
|
|||||||
// Config types
|
// 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.
|
/// Top-level `.botsecrets` configuration.
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct SecretsConfig {
|
pub struct SecretsConfig {
|
||||||
@@ -53,6 +64,8 @@ pub struct SecretsConfig {
|
|||||||
pub enforcement: EnforcementConfig,
|
pub enforcement: EnforcementConfig,
|
||||||
#[serde(default, rename = "file")]
|
#[serde(default, rename = "file")]
|
||||||
pub file_overrides: Vec<FileOverride>,
|
pub file_overrides: Vec<FileOverride>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub policy: Option<PolicyConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
@@ -252,6 +265,7 @@ impl Default for SecretsConfig {
|
|||||||
heuristic: HeuristicConfig::default(),
|
heuristic: HeuristicConfig::default(),
|
||||||
enforcement: EnforcementConfig::default(),
|
enforcement: EnforcementConfig::default(),
|
||||||
file_overrides: Vec::new(),
|
file_overrides: Vec::new(),
|
||||||
|
policy: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -320,6 +334,8 @@ struct PartialSecretsConfig {
|
|||||||
enforcement: Option<PartialEnforcementConfig>,
|
enforcement: Option<PartialEnforcementConfig>,
|
||||||
#[serde(default, rename = "file")]
|
#[serde(default, rename = "file")]
|
||||||
file: Option<Vec<FileOverride>>,
|
file: Option<Vec<FileOverride>>,
|
||||||
|
#[serde(default)]
|
||||||
|
policy: Option<PolicyConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize)]
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
@@ -414,6 +430,11 @@ impl SecretsConfig {
|
|||||||
if let Some(overrides) = layer.file {
|
if let Some(overrides) = layer.file {
|
||||||
self.file_overrides = overrides;
|
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 project_file = root.join(".botsecrets");
|
||||||
|
let fermata_file = root.join("fermata.toml");
|
||||||
if project_file.is_file() {
|
if project_file.is_file() {
|
||||||
let layer = Self::read_partial(&project_file)?;
|
let layer = Self::read_partial(&project_file)?;
|
||||||
config.merge_layer(layer);
|
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
|
// Layer 4: local overrides
|
||||||
|
|||||||
@@ -1,14 +1,4 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
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)]
|
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
|
||||||
pub struct OpRules {
|
pub struct OpRules {
|
||||||
@@ -25,23 +15,3 @@ pub struct BashRules {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub allow_prefixes: Vec<String>,
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,36 +2,36 @@ use dirigent_fermata::core::{Decision, Policy};
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn project_with(toml: &str) -> TempDir {
|
fn project_with(botsecrets: &str) -> TempDir {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
fs::write(tmp.path().join("botignore.toml"), toml).unwrap();
|
fs::write(tmp.path().join(".botsecrets"), botsecrets).unwrap();
|
||||||
tmp
|
tmp
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deny_substring_blocks() {
|
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();
|
let p = Policy::load(tmp.path()).unwrap();
|
||||||
assert!(matches!(p.check_command("sudo rm -rf / now").unwrap(), Decision::Deny(_)));
|
assert!(matches!(p.check_command("sudo rm -rf / now").unwrap(), Decision::Deny(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deny_glob_blocks() {
|
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();
|
let p = Policy::load(tmp.path()).unwrap();
|
||||||
assert!(matches!(p.check_command("git push --force-with-lease").unwrap(), Decision::Deny(_)));
|
assert!(matches!(p.check_command("git push --force-with-lease").unwrap(), Decision::Deny(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ask_returns_ask() {
|
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();
|
let p = Policy::load(tmp.path()).unwrap();
|
||||||
assert!(matches!(p.check_command("rm somefile").unwrap(), Decision::Ask(_)));
|
assert!(matches!(p.check_command("rm somefile").unwrap(), Decision::Ask(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn allow_prefixes_allows() {
|
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();
|
let p = Policy::load(tmp.path()).unwrap();
|
||||||
assert_eq!(p.check_command("make test").unwrap(), Decision::Allow);
|
assert_eq!(p.check_command("make test").unwrap(), Decision::Allow);
|
||||||
assert_eq!(p.check_command("make test-unit").unwrap(), Decision::Allow);
|
assert_eq!(p.check_command("make test-unit").unwrap(), Decision::Allow);
|
||||||
@@ -46,7 +46,7 @@ fn no_rules_means_allow() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deny_takes_precedence_over_allow_prefix() {
|
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();
|
let p = Policy::load(tmp.path()).unwrap();
|
||||||
assert!(matches!(p.check_command("rm -rf /").unwrap(), Decision::Deny(_)));
|
assert!(matches!(p.check_command("rm -rf /").unwrap(), Decision::Deny(_)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ use dirigent_fermata::core::{Decision, Op, Policy};
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use tempfile::TempDir;
|
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();
|
let tmp = TempDir::new().unwrap();
|
||||||
|
if !botignore.is_empty() {
|
||||||
fs::write(tmp.path().join(".botignore"), botignore).unwrap();
|
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
|
tmp
|
||||||
}
|
}
|
||||||
@@ -42,8 +44,8 @@ fn unmatched_path_allowed() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn toml_read_block_applies_only_to_read() {
|
fn policy_read_block_applies_only_to_read() {
|
||||||
let tmp = make_project("", "[read]\npatterns = [\"secrets/**\"]\n");
|
let tmp = make_project("", "[policy.read]\npatterns = [\"secrets/**\"]\n");
|
||||||
let policy = Policy::load(tmp.path()).unwrap();
|
let policy = Policy::load(tmp.path()).unwrap();
|
||||||
let target = tmp.path().join("secrets/key.pem");
|
let target = tmp.path().join("secrets/key.pem");
|
||||||
fs::create_dir_all(target.parent().unwrap()).unwrap();
|
fs::create_dir_all(target.parent().unwrap()).unwrap();
|
||||||
@@ -53,8 +55,8 @@ fn toml_read_block_applies_only_to_read() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn toml_write_block_applies_only_to_write() {
|
fn policy_write_block_applies_only_to_write() {
|
||||||
let tmp = make_project("", "[write]\npatterns = [\"vendor/**\"]\n");
|
let tmp = make_project("", "[policy.write]\npatterns = [\"vendor/**\"]\n");
|
||||||
let policy = Policy::load(tmp.path()).unwrap();
|
let policy = Policy::load(tmp.path()).unwrap();
|
||||||
let target = tmp.path().join("vendor/lib.rs");
|
let target = tmp.path().join("vendor/lib.rs");
|
||||||
fs::create_dir_all(target.parent().unwrap()).unwrap();
|
fs::create_dir_all(target.parent().unwrap()).unwrap();
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ use std::fs;
|
|||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn finds_botignore_toml_first() {
|
fn finds_fermata_toml() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let root = tmp.path();
|
let root = tmp.path();
|
||||||
fs::create_dir_all(root.join("sub/deep")).unwrap();
|
fs::create_dir_all(root.join("sub/deep")).unwrap();
|
||||||
fs::write(root.join("botignore.toml"), "").unwrap();
|
fs::write(root.join("fermata.toml"), "").unwrap();
|
||||||
fs::write(root.join(".botignore.toml"), "").unwrap();
|
|
||||||
fs::create_dir_all(root.join(".git")).unwrap();
|
fs::create_dir_all(root.join(".git")).unwrap();
|
||||||
|
|
||||||
let target = root.join("sub/deep/file.rs");
|
let target = root.join("sub/deep/file.rs");
|
||||||
@@ -19,11 +18,11 @@ fn finds_botignore_toml_first() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn finds_dot_botignore_toml() {
|
fn finds_botsecrets() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let root = tmp.path();
|
let root = tmp.path();
|
||||||
fs::create_dir_all(root.join("sub")).unwrap();
|
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");
|
let target = root.join("sub/file.rs");
|
||||||
fs::write(&target, "").unwrap();
|
fs::write(&target, "").unwrap();
|
||||||
@@ -110,7 +109,7 @@ fn walks_up_from_file_path_not_cwd() {
|
|||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let root = tmp.path();
|
let root = tmp.path();
|
||||||
fs::create_dir_all(root.join("a/b/c")).unwrap();
|
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");
|
let target = root.join("a/b/c/file.rs");
|
||||||
fs::write(&target, "").unwrap();
|
fs::write(&target, "").unwrap();
|
||||||
|
|||||||
+24
-36
@@ -1,47 +1,35 @@
|
|||||||
use dirigent_fermata::core::toml_config::{BotignoreToml, OpRules, BashRules};
|
use dirigent_fermata::core::toml_config::{OpRules, BashRules};
|
||||||
|
|
||||||
#[test]
|
#[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#"
|
let src = r#"
|
||||||
[read]
|
|
||||||
patterns = [".env*", "secrets/**"]
|
|
||||||
|
|
||||||
[write]
|
|
||||||
patterns = ["vendor/**", "*.lock"]
|
|
||||||
|
|
||||||
[bash]
|
|
||||||
deny = ["rm -rf /", "git push --force*"]
|
deny = ["rm -rf /", "git push --force*"]
|
||||||
ask = ["rm:*"]
|
ask = ["rm:*"]
|
||||||
allow_prefixes = ["make test", "git checkout:*"]
|
allow_prefixes = ["make test", "git checkout:*"]
|
||||||
"#;
|
"#;
|
||||||
let cfg: BotignoreToml = toml::from_str(src).unwrap();
|
let rules: BashRules = toml::from_str(src).unwrap();
|
||||||
assert_eq!(cfg.read.unwrap().patterns, vec![".env*", "secrets/**"]);
|
assert_eq!(rules.deny, vec!["rm -rf /", "git push --force*"]);
|
||||||
assert_eq!(cfg.write.unwrap().patterns, vec!["vendor/**", "*.lock"]);
|
assert_eq!(rules.ask, vec!["rm:*"]);
|
||||||
let bash = cfg.bash.unwrap();
|
assert_eq!(rules.allow_prefixes, vec!["make test", "git checkout:*"]);
|
||||||
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:*"]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_config_is_valid() {
|
fn bash_rules_default_is_empty() {
|
||||||
let cfg: BotignoreToml = toml::from_str("").unwrap();
|
let rules = BashRules::default();
|
||||||
assert!(cfg.read.is_none());
|
assert!(rules.deny.is_empty());
|
||||||
assert!(cfg.write.is_none());
|
assert!(rules.ask.is_empty());
|
||||||
assert!(cfg.bash.is_none());
|
assert!(rules.allow_prefixes.is_empty());
|
||||||
}
|
|
||||||
|
|
||||||
#[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());
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ fn fixture() -> TempDir {
|
|||||||
let root = tmp.path();
|
let root = tmp.path();
|
||||||
fs::write(root.join(".botignore"), ".env\n.env.*\nconf/cert/**\nconf/mitmproxy/**\n").unwrap();
|
fs::write(root.join(".botignore"), ".env\n.env.*\nconf/cert/**\nconf/mitmproxy/**\n").unwrap();
|
||||||
fs::write(
|
fs::write(
|
||||||
root.join("botignore.toml"),
|
root.join(".botsecrets"),
|
||||||
r#"
|
r#"
|
||||||
[read]
|
[policy.read]
|
||||||
patterns = [
|
patterns = [
|
||||||
"conf/localtestsettings.yaml",
|
"conf/localtestsettings.yaml",
|
||||||
"conf/localsettings.yaml",
|
"conf/localsettings.yaml",
|
||||||
@@ -19,14 +19,14 @@ patterns = [
|
|||||||
".claude/self-reflections/**",
|
".claude/self-reflections/**",
|
||||||
]
|
]
|
||||||
|
|
||||||
[write]
|
[policy.write]
|
||||||
patterns = [
|
patterns = [
|
||||||
"conf/localtestsettings.yaml",
|
"conf/localtestsettings.yaml",
|
||||||
"conf/localsettings.yaml",
|
"conf/localsettings.yaml",
|
||||||
"conf/default-secrets.yaml",
|
"conf/default-secrets.yaml",
|
||||||
]
|
]
|
||||||
|
|
||||||
[bash]
|
[policy.bash]
|
||||||
deny = ["localtestsettings.yaml", "localsettings.yaml", "default-secrets.yaml", ".env"]
|
deny = ["localtestsettings.yaml", "localsettings.yaml", "default-secrets.yaml", ".env"]
|
||||||
ask = ["rm *", "mv *"]
|
ask = ["rm *", "mv *"]
|
||||||
allow_prefixes = ["make test"]
|
allow_prefixes = ["make test"]
|
||||||
@@ -103,7 +103,7 @@ fn bash_rm_somefile_asks() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn read_self_reflections_asks() {
|
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.
|
// for hard reads. This documents the gap; once toml has a `[read].ask`, switch to Ask.
|
||||||
let t = fixture();
|
let t = fixture();
|
||||||
let p = Policy::load(t.path()).unwrap();
|
let p = Policy::load(t.path()).unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user