sync from monorepo @ 2452e92e
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
# Package: dirigent_fermata
|
||||
|
||||
Harness-agnostic policy gate for AI coding agents.
|
||||
|
||||
## Quick Facts
|
||||
- **Type**: Library + binary (`fermata`)
|
||||
- **Main Entry**: `src/lib.rs`, `src/bin/fermata.rs`
|
||||
- **Dependencies**: `ignore`, `toml`, `regex`, `globset`, `serde`, `clap` (cli feature)
|
||||
- **Status**: v0.1 — library + CLI + Claude hook adapter
|
||||
|
||||
## Layering
|
||||
|
||||
Three concentric layers; nothing inner imports from anything outer.
|
||||
|
||||
- **`core/`** — harness-unaware, transport-unaware, sync. Types (`Op`, `Decision`), `.botignore` walker, `botignore.toml` parser, `Policy::check` / `check_command`, path extraction. Sync, no tokio.
|
||||
- **`harness/`** — `HarnessAdapter` trait over a normalized `ToolCall`. Each adapter (Claude, future Codex, etc.) lives in its own submodule, feature-gated.
|
||||
- **`bin/fermata.rs`** — only place where `clap`, stdio, and exit codes appear.
|
||||
|
||||
## Release Model
|
||||
|
||||
Developed in this monorepo; planned to be exported as a standalone repo in the future for advertising / external distribution. Development stays here. See `docs/tools/fermata.md`.
|
||||
|
||||
## Dependency Direction
|
||||
|
||||
`dirigent_tools` depends on `dirigent_fermata`, never the reverse. Fermata must remain usable as a standalone hook/MCP without dragging in the in-process ACP tool runtime.
|
||||
|
||||
## Out of scope (v0.1)
|
||||
|
||||
Codex / Gemini hook adapters, MCP server mode, PostToolUse envelope, `readonly_only` Bash mode, audit log, filesystem watcher. Each is a future task with its own plan.
|
||||
|
||||
## See also
|
||||
|
||||
- `docs/tools/fermata.md` — Dirigent integration plan
|
||||
- `docs/workpad/brainstorm/fermata.md` — canonical product spec
|
||||
@@ -0,0 +1,40 @@
|
||||
[package]
|
||||
name = "dirigent_fermata"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.75"
|
||||
description = "Harness-agnostic policy gate for AI coding agents (.botignore + botignore.toml)"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://git.g4b.org/dirigence/fermata"
|
||||
readme = "README.md"
|
||||
keywords = ["ai", "agents", "security", "policy", "gitignore"]
|
||||
categories = ["command-line-utilities", "development-tools"]
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "fermata"
|
||||
path = "src/bin/fermata.rs"
|
||||
required-features = ["cli"]
|
||||
|
||||
[dependencies]
|
||||
globset = "0.4"
|
||||
ignore = "0.4"
|
||||
walkdir = "2"
|
||||
toml = "0.8"
|
||||
regex = "1.10"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
thiserror = "2.0"
|
||||
clap = { version = "4.5", features = ["derive"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.10"
|
||||
assert_cmd = "2.0"
|
||||
predicates = "3.1"
|
||||
|
||||
[features]
|
||||
default = ["cli", "harness-claude"]
|
||||
cli = ["dep:clap"]
|
||||
harness-claude = []
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for describing the origin of the Work and
|
||||
reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Support. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or support.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Gabor Körber and contributors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Gabor Körber and contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,214 @@
|
||||
# 𝄐 dirigent_fermata
|
||||
|
||||
**A fast, harness-agnostic policy gate for AI coding agents.**
|
||||
|
||||
Drop a `.botignore` file in your project root. Fermata reads it and blocks your agent from reading, writing, or running things it shouldn't — before the tool call happens.
|
||||
|
||||
```
|
||||
.env
|
||||
.env.*
|
||||
secrets/**
|
||||
conf/localsettings.yaml
|
||||
```
|
||||
|
||||
That's all it takes.
|
||||
|
||||
---
|
||||
|
||||
## Why Fermata
|
||||
|
||||
AI coding agents are powerful, but they don't have an innate sense of "don't touch `.env`." Native hook systems in tools like Claude Code let you intercept every file operation — but wiring up your own secure, fast hook for each project is friction. Fermata is that hook, ready to drop in.
|
||||
|
||||
- **Fast** — written in Rust; ~1–5ms per call. Hooks fire on every read, write, and bash operation. Python cold-start (~50–150ms) compounds fast. Fermata doesn't.
|
||||
- **Familiar syntax** — `.botignore` uses gitignore rules via the `ignore` crate (the same engine powering ripgrep).
|
||||
- **Per-operation control** — `botignore.toml` lets you block writes to `vendor/**` while still allowing reads, or deny specific bash patterns without touching path rules.
|
||||
- **Harness-agnostic** — plain CLI exit codes work from any shell wrapper; the hook adapter speaks Claude Code's JSON natively.
|
||||
|
||||
---
|
||||
|
||||
## Status: v0.1
|
||||
|
||||
| Component | Status |
|
||||
|-----------|--------|
|
||||
| Library (`Op`, `Decision`, `Policy::check`, `Policy::check_command`) | Done |
|
||||
| `.botignore` walker (project-root walk-up, gitignore semantics) | Done |
|
||||
| `botignore.toml` parser (read / write / bash namespaces) | Done |
|
||||
| Path identification heuristics | Done |
|
||||
| CLI: `fermata check <path>...` | Done |
|
||||
| CLI: `fermata hook --harness claude` | Done |
|
||||
| Claude Code PreToolUse adapter | Done |
|
||||
|
||||
Out of scope for v0.1: Codex / Gemini hook adapters, MCP server mode, audit log, filesystem watcher.
|
||||
|
||||
---
|
||||
|
||||
## Install
|
||||
|
||||
From source (this monorepo):
|
||||
|
||||
```bash
|
||||
cargo install --path crates/dirigent_fermata --features cli
|
||||
```
|
||||
|
||||
This installs the `fermata` binary into `~/.cargo/bin/`.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Checking a path
|
||||
|
||||
```bash
|
||||
fermata check --op read /path/to/.env
|
||||
# exit 1 — blocked
|
||||
# stderr: blocked by rule ".env" in /your/project/.botignore
|
||||
|
||||
fermata check --op write /path/to/src/main.rs
|
||||
# exit 0 — allowed
|
||||
```
|
||||
|
||||
### Claude Code hook adapter
|
||||
|
||||
```bash
|
||||
fermata hook --harness claude < hook_payload.json
|
||||
```
|
||||
|
||||
Reads the PreToolUse JSON from stdin, extracts the tool name and path or command, applies policy, and emits the Claude-shaped JSON response. The hook's exit code is always `0`; the verdict is in the JSON body.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### `.botignore` — the 80% case
|
||||
|
||||
Create a `.botignore` at your project root. Gitignore syntax. Blocks both reads and writes.
|
||||
|
||||
```gitignore
|
||||
# Secrets
|
||||
.env
|
||||
.env.*
|
||||
secrets/**
|
||||
|
||||
# Local config overrides
|
||||
conf/localsettings.yaml
|
||||
conf/localtestsettings.yaml
|
||||
|
||||
# Generated files — let the tools rebuild them, not patch them
|
||||
dist/**
|
||||
*.lock
|
||||
```
|
||||
|
||||
Fermata walks up from the target file to find the nearest `.botignore`, so it works correctly even when an agent changes directory.
|
||||
|
||||
### `botignore.toml` — per-operation rules
|
||||
|
||||
For cases where `.botignore`'s uniform read+write block isn't granular enough:
|
||||
|
||||
```toml
|
||||
[read]
|
||||
# Block reading secrets outright
|
||||
patterns = [".env*", "secrets/**", "conf/localsettings.yaml"]
|
||||
|
||||
[write]
|
||||
# Allow reading vendor code but block patching it
|
||||
patterns = ["vendor/**", "*.lock"]
|
||||
|
||||
[bash]
|
||||
# Hard-block destructive or exfiltrating commands
|
||||
deny = [
|
||||
"rm -rf /",
|
||||
"curl * | sh",
|
||||
"git push --force*",
|
||||
]
|
||||
# Ask before any removal or move
|
||||
ask = ["rm:*", "mv:*"]
|
||||
# Narrow allowlist for automated commands
|
||||
allow_prefixes = ["make test", "git checkout:*"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How it fits into Claude Code
|
||||
|
||||
Add fermata as a `PreToolUse` hook in `.claude/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash|Read|Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "fermata hook --harness claude"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When Claude attempts a `Read(.env)`, `Write(vendor/foo.js)`, or `Bash(rm ./secrets/key.pem)`, fermata intercepts the call, checks policy, and returns a deny with a human-readable reason — before any damage is done.
|
||||
|
||||
---
|
||||
|
||||
## Real-world scenario
|
||||
|
||||
A project has `.env`, `conf/localsettings.yaml`, and a `vendor/` tree it doesn't want patched. With `.botignore`:
|
||||
|
||||
```gitignore
|
||||
.env
|
||||
.env.*
|
||||
conf/localsettings.yaml
|
||||
vendor/**
|
||||
```
|
||||
|
||||
Claude attempts to read credentials:
|
||||
|
||||
```
|
||||
Tool: Read
|
||||
Path: ./conf/localsettings.yaml
|
||||
Decision: BLOCK — matched rule "conf/localsettings.yaml" (.botignore)
|
||||
```
|
||||
|
||||
Claude attempts to read application code:
|
||||
|
||||
```
|
||||
Tool: Read
|
||||
Path: ./src/app/main.rs
|
||||
Decision: ALLOW
|
||||
```
|
||||
|
||||
Claude attempts to run `cat .env` via bash — which would bypass a path-only check:
|
||||
|
||||
```toml
|
||||
# botignore.toml
|
||||
[bash]
|
||||
deny = ["cat .env*", "cat conf/localsettings*"]
|
||||
```
|
||||
|
||||
```
|
||||
Tool: Bash
|
||||
Command: cat .env
|
||||
Decision: BLOCK — matched bash deny rule "cat .env*"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
Three concentric layers; nothing inner imports from anything outer:
|
||||
|
||||
- **`core/`** — harness-unaware, sync. Types, `.botignore` walker, `botignore.toml` parser, `Policy::check` / `check_command`, path extraction.
|
||||
- **`harness/`** — `HarnessAdapter` trait over a normalized `ToolCall`. Each adapter lives in its own submodule, feature-gated.
|
||||
- **`bin/fermata.rs`** — the only place `clap`, stdio, and exit codes appear.
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- `docs/tools/fermata.md` — Dirigent integration plan
|
||||
- `docs/workpad/brainstorm/fermata.md` — full product spec and field notes
|
||||
- `docs/architecture/crates.md` — crate dependency map
|
||||
@@ -0,0 +1,205 @@
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
use dirigent_fermata::core::{project::find_project_root, Decision, Op, Policy};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "fermata", about = "Harness-agnostic policy gate for AI coding agents")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Check whether `path` is allowed for the given `--op`.
|
||||
Check {
|
||||
#[arg(long, value_enum, default_value_t = OpArg::Read)]
|
||||
op: OpArg,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
paths: Vec<PathBuf>,
|
||||
},
|
||||
/// Read a harness hook payload from stdin and render the decision.
|
||||
Hook {
|
||||
#[arg(long)]
|
||||
harness: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, ValueEnum)]
|
||||
enum OpArg {
|
||||
Read,
|
||||
Write,
|
||||
Execute,
|
||||
}
|
||||
|
||||
impl From<OpArg> for Op {
|
||||
fn from(a: OpArg) -> Self {
|
||||
match a {
|
||||
OpArg::Read => Op::Read,
|
||||
OpArg::Write => Op::Write,
|
||||
OpArg::Execute => Op::Execute,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let cli = Cli::parse();
|
||||
match cli.cmd {
|
||||
Cmd::Check { op, json, paths } => run_check(op.into(), json, &paths),
|
||||
Cmd::Hook { harness } => run_hook(&harness),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_check(op: Op, json: bool, paths: &[PathBuf]) -> ExitCode {
|
||||
let mut worst: Option<Decision> = None;
|
||||
for p in paths {
|
||||
let root = match find_project_root(p) {
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
};
|
||||
let policy = match Policy::load(&root) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("fermata: load error: {e}");
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
};
|
||||
let d = match policy.check(op, p) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
eprintln!("fermata: check error: {e}");
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
};
|
||||
worst = Some(merge_worst(worst.take(), d));
|
||||
}
|
||||
let decision = worst.unwrap_or(Decision::Allow);
|
||||
if json {
|
||||
let _ = serde_json::to_writer(std::io::stdout().lock(), &decision);
|
||||
let _ = writeln!(std::io::stdout().lock());
|
||||
} else if let Decision::Deny(ref r) = decision {
|
||||
println!("{}", r.message);
|
||||
} else if let Decision::Ask(ref r) = decision {
|
||||
println!("ASK: {}", r.message);
|
||||
}
|
||||
match decision {
|
||||
Decision::Allow => ExitCode::from(0),
|
||||
Decision::Ask(_) => ExitCode::from(0),
|
||||
Decision::Deny(_) => ExitCode::from(1),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_hook(harness: &str) -> ExitCode {
|
||||
let adapter = match dirigent_fermata::harness::lookup(harness) {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
eprintln!("fermata: unknown harness '{harness}'");
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
if let Err(e) = std::io::stdin().lock().read_to_end(&mut buf) {
|
||||
eprintln!("fermata: stdin: {e}");
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
let call = match adapter.parse_request(&buf) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("fermata: parse: {e}");
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
};
|
||||
|
||||
use dirigent_fermata::harness::{PathKind, ToolOp};
|
||||
let decision = match &call.op {
|
||||
ToolOp::Path { path, kind } => {
|
||||
let root = match find_project_root(path) {
|
||||
// No project root → fail-open allow (hook must always exit 0 with a verdict).
|
||||
// run_check silently skips these paths; here we must still emit JSON.
|
||||
Some(r) => r,
|
||||
None => {
|
||||
let out = adapter.render_decision(&call, &Decision::Allow).unwrap_or_default();
|
||||
let _ = std::io::stdout().lock().write_all(&out);
|
||||
return ExitCode::from(0);
|
||||
}
|
||||
};
|
||||
let policy = match Policy::load(&root) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("fermata: load error: {e}");
|
||||
let out = adapter.render_decision(&call, &Decision::Allow).unwrap_or_default();
|
||||
let _ = std::io::stdout().lock().write_all(&out);
|
||||
return ExitCode::from(0);
|
||||
}
|
||||
};
|
||||
let op = match kind {
|
||||
PathKind::Read => Op::Read,
|
||||
PathKind::Write => Op::Write,
|
||||
};
|
||||
match policy.check(op, path) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
eprintln!("fermata: check error: {e}");
|
||||
let out = adapter.render_decision(&call, &Decision::Allow).unwrap_or_default();
|
||||
let _ = std::io::stdout().lock().write_all(&out);
|
||||
return ExitCode::from(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolOp::Command { text } => {
|
||||
// For commands, we look up the project from cwd (no path argument).
|
||||
let cwd = match std::env::current_dir() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
eprintln!("fermata: cwd error: {e}");
|
||||
let out = adapter.render_decision(&call, &Decision::Allow).unwrap_or_default();
|
||||
let _ = std::io::stdout().lock().write_all(&out);
|
||||
return ExitCode::from(0);
|
||||
}
|
||||
};
|
||||
match find_project_root(&cwd) {
|
||||
// No project root → fail-open allow (see Path branch note above).
|
||||
None => Decision::Allow,
|
||||
Some(root) => {
|
||||
let policy = match Policy::load(&root) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("fermata: load error: {e}");
|
||||
let out = adapter.render_decision(&call, &Decision::Allow).unwrap_or_default();
|
||||
let _ = std::io::stdout().lock().write_all(&out);
|
||||
return ExitCode::from(0);
|
||||
}
|
||||
};
|
||||
match policy.check_command(text) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
eprintln!("fermata: check error: {e}");
|
||||
let out = adapter.render_decision(&call, &Decision::Allow).unwrap_or_default();
|
||||
let _ = std::io::stdout().lock().write_all(&out);
|
||||
return ExitCode::from(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let out = adapter.render_decision(&call, &decision).unwrap_or_default();
|
||||
let _ = std::io::stdout().lock().write_all(&out);
|
||||
ExitCode::from(0) // hook bins always exit 0; the JSON carries the verdict
|
||||
}
|
||||
|
||||
fn merge_worst(a: Option<Decision>, b: Decision) -> Decision {
|
||||
let rank = |d: &Decision| match d {
|
||||
Decision::Allow => 0,
|
||||
Decision::Ask(_) => 1,
|
||||
Decision::Deny(_) => 2,
|
||||
};
|
||||
match a {
|
||||
None => b,
|
||||
Some(a) if rank(&a) >= rank(&b) => a,
|
||||
Some(_) => b,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use crate::core::decision::Rule;
|
||||
use ignore::gitignore::{Gitignore, GitignoreBuilder};
|
||||
use std::path::{Path, PathBuf};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum BotignoreError {
|
||||
#[error("failed to read .botignore: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("failed to compile .botignore: {0}")]
|
||||
Compile(#[source] ignore::Error),
|
||||
}
|
||||
|
||||
struct ScopedMatcher {
|
||||
/// Path of the source `.botignore` file.
|
||||
source: PathBuf,
|
||||
/// Directory the matcher is rooted at (parent of `source`).
|
||||
dir: PathBuf,
|
||||
/// Depth of `dir` (component count) — deeper = more specific.
|
||||
depth: usize,
|
||||
matcher: Gitignore,
|
||||
}
|
||||
|
||||
/// A collection of `.botignore` matchers, one per file discovered under the
|
||||
/// project root. Each matcher is rooted at its source file's directory so
|
||||
/// gitignore-style semantics (anchored vs unanchored patterns, per-directory
|
||||
/// scope) work correctly. At match time, the deepest applicable matcher
|
||||
/// wins; whitelist (`!` negation) at any depth overrides an ignore at
|
||||
/// shallower depth.
|
||||
pub struct BotignoreSet {
|
||||
matchers: Vec<ScopedMatcher>,
|
||||
}
|
||||
|
||||
impl BotignoreSet {
|
||||
/// Walk `root` recursively, building a per-file matcher for every
|
||||
/// `.botignore` encountered. Empty if none are found.
|
||||
pub fn load(root: &Path) -> Result<Self, BotignoreError> {
|
||||
let mut matchers = Vec::new();
|
||||
for entry in walkdir::WalkDir::new(root).into_iter().filter_map(Result::ok) {
|
||||
if !(entry.file_type().is_file() && entry.file_name() == ".botignore") {
|
||||
continue;
|
||||
}
|
||||
let source = entry.path().to_path_buf();
|
||||
let dir = source.parent().unwrap_or(root).to_path_buf();
|
||||
let mut builder = GitignoreBuilder::new(&dir);
|
||||
if let Some(err) = builder.add(&source) {
|
||||
return Err(BotignoreError::Compile(err));
|
||||
}
|
||||
let matcher = builder.build().map_err(BotignoreError::Compile)?;
|
||||
let depth = dir.components().count();
|
||||
matchers.push(ScopedMatcher {
|
||||
source,
|
||||
dir,
|
||||
depth,
|
||||
matcher,
|
||||
});
|
||||
}
|
||||
// Shallowest first so iteration applies broader rules then more-specific overrides.
|
||||
matchers.sort_by_key(|m| m.depth);
|
||||
Ok(Self { matchers })
|
||||
}
|
||||
|
||||
/// Returns `Some(Rule)` if `path` is matched (and not negated by a
|
||||
/// deeper-scoped whitelist), else `None`. The deepest matcher whose
|
||||
/// directory contains `path` wins.
|
||||
pub fn matched(&self, path: &Path) -> Result<Option<Rule>, BotignoreError> {
|
||||
let is_dir = path.is_dir();
|
||||
let mut current: Option<&ScopedMatcher> = None;
|
||||
let mut current_pattern: Option<String> = None;
|
||||
|
||||
for sm in &self.matchers {
|
||||
if !path.starts_with(&sm.dir) {
|
||||
continue;
|
||||
}
|
||||
let m = sm.matcher.matched(path, is_dir);
|
||||
if m.is_ignore() {
|
||||
current = Some(sm);
|
||||
current_pattern = m.inner().map(|g| g.original().to_string());
|
||||
} else if m.is_whitelist() {
|
||||
// Deeper whitelist overrides any shallower ignore.
|
||||
current = None;
|
||||
current_pattern = None;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(current.map(|sm| Rule {
|
||||
source: sm.source.clone(),
|
||||
pattern: current_pattern.unwrap_or_default(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Rule {
|
||||
/// Source file the rule came from (e.g. `/proj/.botignore`).
|
||||
pub source: PathBuf,
|
||||
/// Pattern text as it appeared in the source.
|
||||
pub pattern: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Reason {
|
||||
pub message: String,
|
||||
pub rule: Option<Rule>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||
pub enum Decision {
|
||||
Allow,
|
||||
Ask(Reason),
|
||||
Deny(Reason),
|
||||
}
|
||||
|
||||
impl Decision {
|
||||
pub fn is_blocking(&self) -> bool {
|
||||
matches!(self, Decision::Deny(_))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use regex::Regex;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Confidence {
|
||||
/// Absolute path or path with explicit separator.
|
||||
High,
|
||||
/// Bare filename with extension; could be a word.
|
||||
Low,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PathCandidate {
|
||||
pub text: String,
|
||||
pub confidence: Confidence,
|
||||
}
|
||||
|
||||
/// Heuristically extract path-like substrings from arbitrary text.
|
||||
/// Confident matches (absolute paths, paths containing separators) → `High`.
|
||||
/// Bare filenames with an extension → `Low` (advisory only).
|
||||
pub fn extract_path_candidates(text: &str) -> Vec<PathCandidate> {
|
||||
static UNIX_ABS: OnceLock<Regex> = OnceLock::new();
|
||||
static WIN_ABS: OnceLock<Regex> = OnceLock::new();
|
||||
static REL_WITH_SEP: OnceLock<Regex> = OnceLock::new();
|
||||
static BARE_NAME: OnceLock<Regex> = OnceLock::new();
|
||||
|
||||
let unix_abs = UNIX_ABS.get_or_init(|| Regex::new(r"(?m)(?:^|\s)(/[\w./~\-_]+)").unwrap());
|
||||
let win_abs = WIN_ABS.get_or_init(|| Regex::new(r#"(?m)(?:^|\s)([A-Za-z]:\\[\w.\\\-_]+)"#).unwrap());
|
||||
let rel = REL_WITH_SEP.get_or_init(|| Regex::new(r"(?m)(?:^|\s)((?:\./|\.\./|[\w\-_]+/)[\w./\-_]+)").unwrap());
|
||||
let bare = BARE_NAME.get_or_init(|| Regex::new(r"(?m)(?:^|\s)([\w\-_]+\.[A-Za-z]{1,8})(?:\s|[.,;:!?]|$)").unwrap());
|
||||
|
||||
let mut out = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
for re in [unix_abs, win_abs, rel] {
|
||||
for cap in re.captures_iter(text) {
|
||||
let m = cap.get(1).unwrap().as_str().trim_end_matches(['.', ',', ';', ':', '!', '?']);
|
||||
if seen.insert(m.to_string()) {
|
||||
out.push(PathCandidate { text: m.to_string(), confidence: Confidence::High });
|
||||
}
|
||||
}
|
||||
}
|
||||
for cap in bare.captures_iter(text) {
|
||||
let m = cap.get(1).unwrap().as_str();
|
||||
if seen.insert(m.to_string()) {
|
||||
out.push(PathCandidate { text: m.to_string(), confidence: Confidence::Low });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! Core policy layer. Harness-unaware, transport-unaware, sync.
|
||||
|
||||
pub mod botignore;
|
||||
pub mod decision;
|
||||
pub mod extract;
|
||||
pub mod op;
|
||||
pub mod policy;
|
||||
pub mod project;
|
||||
pub mod toml_config;
|
||||
|
||||
pub use decision::{Decision, Reason, Rule};
|
||||
pub use extract::{extract_path_candidates, Confidence, PathCandidate};
|
||||
pub use op::Op;
|
||||
pub use policy::Policy;
|
||||
@@ -0,0 +1,9 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Op {
|
||||
Read,
|
||||
Write,
|
||||
Execute,
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
use crate::core::botignore::{BotignoreError, BotignoreSet};
|
||||
use crate::core::decision::{Decision, Reason, Rule};
|
||||
use crate::core::op::Op;
|
||||
use crate::core::toml_config::{BotignoreToml, TomlConfigError};
|
||||
use std::path::{Path, PathBuf};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PolicyError {
|
||||
#[error(transparent)]
|
||||
Botignore(#[from] BotignoreError),
|
||||
#[error(transparent)]
|
||||
Toml(#[from] TomlConfigError),
|
||||
#[error("invalid pattern in botignore.toml: {0}")]
|
||||
BadPattern(String),
|
||||
}
|
||||
|
||||
pub struct Policy {
|
||||
root: PathBuf,
|
||||
botignore: BotignoreSet,
|
||||
toml: BotignoreToml,
|
||||
read_globs: globset::GlobSet,
|
||||
write_globs: globset::GlobSet,
|
||||
read_patterns: Vec<String>,
|
||||
write_patterns: Vec<String>,
|
||||
}
|
||||
|
||||
impl Policy {
|
||||
pub fn load(root: &Path) -> Result<Self, PolicyError> {
|
||||
let botignore = BotignoreSet::load(root)?;
|
||||
let toml = BotignoreToml::load(root)?;
|
||||
|
||||
let (read_globs, read_patterns) = compile_globs(
|
||||
toml.read.as_ref().map(|r| r.patterns.as_slice()).unwrap_or(&[]),
|
||||
)?;
|
||||
let (write_globs, write_patterns) = compile_globs(
|
||||
toml.write.as_ref().map(|r| r.patterns.as_slice()).unwrap_or(&[]),
|
||||
)?;
|
||||
|
||||
Ok(Self {
|
||||
root: root.to_path_buf(),
|
||||
botignore,
|
||||
toml,
|
||||
read_globs,
|
||||
write_globs,
|
||||
read_patterns,
|
||||
write_patterns,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn check_command(&self, command: &str) -> Result<Decision, PolicyError> {
|
||||
let bash = match self.toml.bash.as_ref() {
|
||||
Some(b) => b,
|
||||
None => return Ok(Decision::Allow),
|
||||
};
|
||||
|
||||
// 1. Deny wins over everything else.
|
||||
if let Some(pat) = match_command(command, &bash.deny)? {
|
||||
return Ok(Decision::Deny(Reason {
|
||||
message: format!("blocked by botignore.toml [bash.deny]: {}", pat),
|
||||
rule: Some(Rule {
|
||||
source: self.root.join("botignore.toml"),
|
||||
pattern: pat,
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
// 2. Allow prefixes — if any matches, allow.
|
||||
for prefix in &bash.allow_prefixes {
|
||||
if command_matches_prefix(command, prefix) {
|
||||
return Ok(Decision::Allow);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Ask patterns.
|
||||
if let Some(pat) = match_command(command, &bash.ask)? {
|
||||
return Ok(Decision::Ask(Reason {
|
||||
message: format!("requires confirmation [bash.ask]: {}", pat),
|
||||
rule: Some(Rule {
|
||||
source: self.root.join("botignore.toml"),
|
||||
pattern: pat,
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(Decision::Allow)
|
||||
}
|
||||
|
||||
pub fn check(&self, op: Op, path: &Path) -> Result<Decision, PolicyError> {
|
||||
// 1. .botignore is path-only and applies to read+write equally.
|
||||
if matches!(op, Op::Read | Op::Write) {
|
||||
if let Some(rule) = self.botignore.matched(path)? {
|
||||
return Ok(Decision::Deny(Reason {
|
||||
message: format!("blocked by .botignore: {}", rule.pattern),
|
||||
rule: Some(rule),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. botignore.toml namespace-specific rules.
|
||||
let (set, patterns) = match op {
|
||||
Op::Read => (&self.read_globs, &self.read_patterns),
|
||||
Op::Write => (&self.write_globs, &self.write_patterns),
|
||||
Op::Execute => return Ok(Decision::Allow), // path-based check_command handles bash
|
||||
};
|
||||
|
||||
let rel = path.strip_prefix(&self.root).unwrap_or(path);
|
||||
let matches = set.matches(rel);
|
||||
if let Some(idx) = matches.first() {
|
||||
let pattern = patterns[*idx].clone();
|
||||
return Ok(Decision::Deny(Reason {
|
||||
message: format!("blocked by botignore.toml [{:?}]: {}", op, pattern),
|
||||
rule: Some(Rule {
|
||||
source: self.root.join("botignore.toml"),
|
||||
pattern,
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(Decision::Allow)
|
||||
}
|
||||
}
|
||||
|
||||
/// Substring-or-glob match of `command` against `patterns`.
|
||||
/// Patterns containing glob metachars (`*`, `?`, `[`) are treated as globs;
|
||||
/// others are matched as literal substrings.
|
||||
fn match_command(command: &str, patterns: &[String]) -> Result<Option<String>, PolicyError> {
|
||||
for pat in patterns {
|
||||
if is_glob(pat) {
|
||||
let g = globset::Glob::new(pat)
|
||||
.map_err(|e| PolicyError::BadPattern(e.to_string()))?
|
||||
.compile_matcher();
|
||||
if g.is_match(command) {
|
||||
return Ok(Some(pat.clone()));
|
||||
}
|
||||
} else if command.contains(pat.as_str()) {
|
||||
return Ok(Some(pat.clone()));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn is_glob(pat: &str) -> bool {
|
||||
pat.contains('*') || pat.contains('?') || pat.contains('[')
|
||||
}
|
||||
|
||||
/// `prefix` is `"name"` or `"name:*"`. Both treat `name` as a leading word
|
||||
/// boundary in `command`. Mirrors Claude Code's `Bash(name:*)` style.
|
||||
fn command_matches_prefix(command: &str, prefix: &str) -> bool {
|
||||
let needle = prefix.trim_end_matches(":*");
|
||||
command.trim_start().starts_with(needle)
|
||||
}
|
||||
|
||||
fn compile_globs(patterns: &[String]) -> Result<(globset::GlobSet, Vec<String>), PolicyError> {
|
||||
let mut builder = globset::GlobSetBuilder::new();
|
||||
for pat in patterns {
|
||||
let glob = globset::Glob::new(pat).map_err(|e| PolicyError::BadPattern(e.to_string()))?;
|
||||
builder.add(glob);
|
||||
}
|
||||
let set = builder
|
||||
.build()
|
||||
.map_err(|e| PolicyError::BadPattern(e.to_string()))?;
|
||||
Ok((set, patterns.to_vec()))
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Markers checked in priority order when walking up from a target path.
|
||||
const MARKERS: &[&str] = &["botignore.toml", ".botignore", ".git"];
|
||||
|
||||
/// Walk upward from `target` (or its parent if `target` is a file) looking
|
||||
/// for the nearest project root. Roots are identified by the presence of
|
||||
/// any marker in `MARKERS`. Walks from the **target file's location**, not
|
||||
/// from cwd, because agents `cd` around.
|
||||
pub fn find_project_root(target: &Path) -> Option<PathBuf> {
|
||||
let start = if target.is_file() {
|
||||
target.parent()?
|
||||
} else {
|
||||
target
|
||||
};
|
||||
|
||||
let mut current = Some(start);
|
||||
while let Some(dir) = current {
|
||||
for marker in MARKERS {
|
||||
if dir.join(marker).exists() {
|
||||
return Some(dir.to_path_buf());
|
||||
}
|
||||
}
|
||||
current = dir.parent();
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TomlConfigError {
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("toml parse error: {0}")]
|
||||
Parse(#[from] toml::de::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
|
||||
pub struct OpRules {
|
||||
#[serde(default)]
|
||||
pub patterns: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
|
||||
pub struct BashRules {
|
||||
#[serde(default)]
|
||||
pub deny: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub ask: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub allow_prefixes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
|
||||
pub struct BotignoreToml {
|
||||
pub read: Option<OpRules>,
|
||||
pub write: Option<OpRules>,
|
||||
pub bash: Option<BashRules>,
|
||||
}
|
||||
|
||||
impl BotignoreToml {
|
||||
/// Load `<root>/botignore.toml` if present, else return an empty config.
|
||||
pub fn load(root: &Path) -> Result<Self, TomlConfigError> {
|
||||
let path = root.join("botignore.toml");
|
||||
if !path.exists() {
|
||||
return Ok(Self::default());
|
||||
}
|
||||
let text = std::fs::read_to_string(&path)?;
|
||||
let cfg = toml::from_str(&text)?;
|
||||
Ok(cfg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Claude Code hook adapter (PreToolUse).
|
||||
//!
|
||||
//! Wire format: stdin is one JSON object with `tool_name` and `tool_input`.
|
||||
//! Stdout is `{"hookSpecificOutput": {...}}` with exit code 0; the JSON
|
||||
//! carries the verdict.
|
||||
|
||||
use super::{AdapterError, HarnessAdapter, PathKind, ToolCall, ToolOp};
|
||||
use crate::core::Decision;
|
||||
use serde_json::{json, Value};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct ClaudeAdapter;
|
||||
|
||||
impl HarnessAdapter for ClaudeAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
"claude"
|
||||
}
|
||||
|
||||
fn parse_request(&self, input: &[u8]) -> Result<ToolCall, AdapterError> {
|
||||
let v: Value = serde_json::from_slice(input)?;
|
||||
let tool_name = v
|
||||
.get("tool_name")
|
||||
.and_then(|x| x.as_str())
|
||||
.ok_or_else(|| AdapterError::Parse("missing tool_name".into()))?
|
||||
.to_string();
|
||||
let tool_input = v.get("tool_input").cloned().unwrap_or(Value::Null);
|
||||
|
||||
let op = match tool_name.as_str() {
|
||||
"Read" => path_op(&tool_input, PathKind::Read)?,
|
||||
"Write" | "Edit" | "MultiEdit" => path_op(&tool_input, PathKind::Write)?,
|
||||
"Bash" => command_op(&tool_input)?,
|
||||
other => return Err(AdapterError::UnsupportedTool(other.to_string())),
|
||||
};
|
||||
|
||||
Ok(ToolCall {
|
||||
tool_name,
|
||||
op,
|
||||
raw: v,
|
||||
})
|
||||
}
|
||||
|
||||
fn render_decision(&self, _call: &ToolCall, decision: &Decision) -> Result<Vec<u8>, AdapterError> {
|
||||
let (verdict, reason) = match decision {
|
||||
Decision::Allow => ("allow", String::new()),
|
||||
Decision::Ask(r) => ("ask", r.message.clone()),
|
||||
Decision::Deny(r) => ("deny", r.message.clone()),
|
||||
};
|
||||
let out = json!({
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": verdict,
|
||||
"permissionDecisionReason": reason,
|
||||
}
|
||||
});
|
||||
Ok(serde_json::to_vec(&out)?)
|
||||
}
|
||||
}
|
||||
|
||||
fn path_op(tool_input: &Value, kind: PathKind) -> Result<ToolOp, AdapterError> {
|
||||
let p = tool_input
|
||||
.get("file_path")
|
||||
.and_then(|x| x.as_str())
|
||||
.ok_or_else(|| AdapterError::Parse("missing tool_input.file_path".into()))?;
|
||||
Ok(ToolOp::Path {
|
||||
path: PathBuf::from(p),
|
||||
kind,
|
||||
})
|
||||
}
|
||||
|
||||
fn command_op(tool_input: &Value) -> Result<ToolOp, AdapterError> {
|
||||
let c = tool_input
|
||||
.get("command")
|
||||
.and_then(|x| x.as_str())
|
||||
.ok_or_else(|| AdapterError::Parse("missing tool_input.command".into()))?;
|
||||
Ok(ToolOp::Command { text: c.to_string() })
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Harness adapter layer. Normalizes harness-specific payloads into
|
||||
//! `core` types and renders `Decision` back to harness wire format.
|
||||
|
||||
use crate::core::Decision;
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AdapterError {
|
||||
#[error("invalid request payload: {0}")]
|
||||
Parse(String),
|
||||
#[error("unsupported tool: {0}")]
|
||||
UnsupportedTool(String),
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("json: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
/// Normalized tool-call shape consumed by `core::Policy`.
|
||||
/// Adapters translate harness-specific payloads into this; nothing in
|
||||
/// `core` knows about adapters.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ToolCall {
|
||||
/// Harness's tool name (e.g. "Read", "Write", "Edit", "Bash").
|
||||
pub tool_name: String,
|
||||
/// Op classification derived from `tool_name`.
|
||||
pub op: ToolOp,
|
||||
/// Original raw payload for the adapter to consult when rendering.
|
||||
pub raw: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ToolOp {
|
||||
Path { path: PathBuf, kind: PathKind },
|
||||
Command { text: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PathKind {
|
||||
Read,
|
||||
Write,
|
||||
}
|
||||
|
||||
/// Trait implemented by each harness adapter. Adapters parse the harness's
|
||||
/// hook stdin payload into `ToolCall` and render a `Decision` back to the
|
||||
/// harness's expected stdout format.
|
||||
pub trait HarnessAdapter {
|
||||
/// The CLI name (e.g. "claude", "codex", "gemini").
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
fn parse_request(&self, input: &[u8]) -> Result<ToolCall, AdapterError>;
|
||||
|
||||
fn render_decision(&self, call: &ToolCall, decision: &Decision) -> Result<Vec<u8>, AdapterError>;
|
||||
}
|
||||
|
||||
#[cfg(feature = "harness-claude")]
|
||||
pub mod claude;
|
||||
|
||||
/// Look up a registered adapter by CLI name.
|
||||
pub fn lookup(name: &str) -> Option<Box<dyn HarnessAdapter>> {
|
||||
match name {
|
||||
#[cfg(feature = "harness-claude")]
|
||||
"claude" => Some(Box::new(claude::ClaudeAdapter)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! `dirigent_fermata` — harness-agnostic policy gate.
|
||||
//!
|
||||
//! See `docs/tools/fermata.md` (Dirigent integration plan) and
|
||||
//! `docs/workpad/brainstorm/fermata.md` (product spec).
|
||||
|
||||
pub mod core;
|
||||
pub mod harness;
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Guards that fermata's Cargo.toml carries the metadata required for
|
||||
//! `cargo publish` and a useful `cargo install`. Reads the manifest as
|
||||
//! plain text to avoid pulling cargo internals.
|
||||
|
||||
use std::fs;
|
||||
|
||||
fn manifest() -> String {
|
||||
fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"))
|
||||
.expect("read Cargo.toml")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_license() {
|
||||
let m = manifest();
|
||||
assert!(m.contains("license ="), "Cargo.toml missing `license` field");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_repository() {
|
||||
let m = manifest();
|
||||
assert!(m.contains("repository ="), "Cargo.toml missing `repository` field");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_description() {
|
||||
let m = manifest();
|
||||
assert!(m.contains("description ="), "Cargo.toml missing `description`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_readme() {
|
||||
let m = manifest();
|
||||
assert!(m.contains("readme ="), "Cargo.toml missing `readme` field");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_keywords_and_categories() {
|
||||
let m = manifest();
|
||||
assert!(m.contains("keywords ="), "Cargo.toml missing `keywords`");
|
||||
assert!(m.contains("categories ="), "Cargo.toml missing `categories`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_rust_version() {
|
||||
let m = manifest();
|
||||
assert!(m.contains("rust-version ="), "Cargo.toml missing `rust-version` (MSRV)");
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use assert_cmd::Command;
|
||||
use predicates::prelude::*;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn check_blocks_botignore_match() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fs::write(tmp.path().join(".botignore"), ".env\n").unwrap();
|
||||
let target = tmp.path().join(".env");
|
||||
fs::write(&target, "").unwrap();
|
||||
|
||||
Command::cargo_bin("fermata")
|
||||
.unwrap()
|
||||
.args(["check", "--op", "read", target.to_str().unwrap()])
|
||||
.assert()
|
||||
.failure()
|
||||
.code(1)
|
||||
.stdout(predicate::str::contains(".env"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_allows_unmatched() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fs::write(tmp.path().join(".botignore"), ".env\n").unwrap();
|
||||
let target = tmp.path().join("src.rs");
|
||||
fs::write(&target, "").unwrap();
|
||||
|
||||
Command::cargo_bin("fermata")
|
||||
.unwrap()
|
||||
.args(["check", "--op", "read", target.to_str().unwrap()])
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_emits_json_with_flag() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fs::write(tmp.path().join(".botignore"), ".env\n").unwrap();
|
||||
let target = tmp.path().join(".env");
|
||||
fs::write(&target, "").unwrap();
|
||||
|
||||
let out = Command::cargo_bin("fermata")
|
||||
.unwrap()
|
||||
.args(["check", "--op", "read", "--json", target.to_str().unwrap()])
|
||||
.assert()
|
||||
.failure()
|
||||
.get_output()
|
||||
.stdout
|
||||
.clone();
|
||||
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
|
||||
assert_eq!(v["kind"], "deny");
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
use assert_cmd::Command;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn hook_blocks_read_of_botignore_match() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fs::write(tmp.path().join(".botignore"), ".env\n").unwrap();
|
||||
let target = tmp.path().join(".env");
|
||||
fs::write(&target, "").unwrap();
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"tool_name": "Read",
|
||||
"tool_input": { "file_path": target.to_str().unwrap() }
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let out = Command::cargo_bin("fermata")
|
||||
.unwrap()
|
||||
.args(["hook", "--harness", "claude"])
|
||||
.write_stdin(payload)
|
||||
.assert()
|
||||
.success() // hook always exits 0
|
||||
.get_output()
|
||||
.stdout
|
||||
.clone();
|
||||
|
||||
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
|
||||
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
|
||||
assert!(v["hookSpecificOutput"]["permissionDecisionReason"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains(".env"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_allows_unrelated_read() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fs::write(tmp.path().join(".botignore"), ".env\n").unwrap();
|
||||
let target = tmp.path().join("src.rs");
|
||||
fs::write(&target, "").unwrap();
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"tool_name": "Read",
|
||||
"tool_input": { "file_path": target.to_str().unwrap() }
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let out = Command::cargo_bin("fermata")
|
||||
.unwrap()
|
||||
.args(["hook", "--harness", "claude"])
|
||||
.write_stdin(payload)
|
||||
.assert()
|
||||
.success()
|
||||
.get_output()
|
||||
.stdout
|
||||
.clone();
|
||||
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
|
||||
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "allow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_unknown_harness_errors() {
|
||||
Command::cargo_bin("fermata")
|
||||
.unwrap()
|
||||
.args(["hook", "--harness", "doesnotexist"])
|
||||
.write_stdin("{}")
|
||||
.assert()
|
||||
.code(2);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
use dirigent_fermata::core::botignore::BotignoreSet;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn matches_simple_pattern() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::write(root.join(".botignore"), ".env\nsecrets/\n").unwrap();
|
||||
|
||||
let set = BotignoreSet::load(root).unwrap();
|
||||
|
||||
let env = root.join(".env");
|
||||
fs::write(&env, "").unwrap();
|
||||
let m = set.matched(&env).unwrap();
|
||||
assert!(m.is_some(), ".env should be matched");
|
||||
assert_eq!(m.unwrap().pattern, ".env");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_match_unrelated_files() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::write(root.join(".botignore"), ".env\n").unwrap();
|
||||
|
||||
let set = BotignoreSet::load(root).unwrap();
|
||||
|
||||
let other = root.join("README.md");
|
||||
fs::write(&other, "").unwrap();
|
||||
assert!(set.matched(&other).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negation_pattern_excludes() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::write(root.join(".botignore"), "*.log\n!keep.log\n").unwrap();
|
||||
|
||||
let set = BotignoreSet::load(root).unwrap();
|
||||
|
||||
let blocked = root.join("foo.log");
|
||||
fs::write(&blocked, "").unwrap();
|
||||
assert!(set.matched(&blocked).unwrap().is_some());
|
||||
|
||||
let allowed = root.join("keep.log");
|
||||
fs::write(&allowed, "").unwrap();
|
||||
assert!(set.matched(&allowed).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_missing_botignore_is_ok() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let set = BotignoreSet::load(tmp.path()).unwrap();
|
||||
let any = tmp.path().join("anything.txt");
|
||||
std::fs::write(&any, "").unwrap();
|
||||
assert!(set.matched(&any).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_botignore_is_scoped_to_its_directory() {
|
||||
// A `.botignore` in a subdirectory only applies under that subdirectory,
|
||||
// matching gitignore semantics: a sibling file with the same name at the
|
||||
// root is NOT affected.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::create_dir_all(root.join("frontend")).unwrap();
|
||||
fs::write(root.join("frontend/.botignore"), "secret.key\n").unwrap();
|
||||
|
||||
let set = BotignoreSet::load(root).unwrap();
|
||||
|
||||
let blocked = root.join("frontend/secret.key");
|
||||
fs::write(&blocked, "").unwrap();
|
||||
let m = set
|
||||
.matched(&blocked)
|
||||
.unwrap()
|
||||
.expect("frontend/secret.key should match");
|
||||
let src = m.source.to_string_lossy().replace('\\', "/");
|
||||
assert!(
|
||||
src.ends_with("frontend/.botignore"),
|
||||
"Rule.source should point at the nested file; was {}",
|
||||
src,
|
||||
);
|
||||
|
||||
let unblocked = root.join("secret.key");
|
||||
fs::write(&unblocked, "").unwrap();
|
||||
assert!(
|
||||
set.matched(&unblocked).unwrap().is_none(),
|
||||
"top-level secret.key should NOT be matched (rule scoped to frontend/)",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_botignore_anchored_pattern_is_local() {
|
||||
// A leading `/` anchors the pattern to the directory of the .botignore
|
||||
// file it's declared in.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::create_dir_all(root.join("frontend")).unwrap();
|
||||
fs::write(root.join("frontend/.botignore"), "/secret.key\n").unwrap();
|
||||
|
||||
let set = BotignoreSet::load(root).unwrap();
|
||||
|
||||
let blocked = root.join("frontend/secret.key");
|
||||
fs::write(&blocked, "").unwrap();
|
||||
assert!(set.matched(&blocked).unwrap().is_some());
|
||||
|
||||
let unblocked = root.join("secret.key");
|
||||
fs::write(&unblocked, "").unwrap();
|
||||
assert!(
|
||||
set.matched(&unblocked).unwrap().is_none(),
|
||||
"anchored /secret.key should NOT match outside frontend/",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_botignore_overrides_root() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::write(root.join(".botignore"), "*.log\n").unwrap();
|
||||
fs::create_dir_all(root.join("logs")).unwrap();
|
||||
fs::write(root.join("logs/.botignore"), "!keep.log\n").unwrap();
|
||||
|
||||
let set = BotignoreSet::load(root).unwrap();
|
||||
|
||||
let blocked = root.join("logs/foo.log");
|
||||
fs::write(&blocked, "").unwrap();
|
||||
assert!(set.matched(&blocked).unwrap().is_some());
|
||||
|
||||
let kept = root.join("logs/keep.log");
|
||||
fs::write(&kept, "").unwrap();
|
||||
assert!(
|
||||
set.matched(&kept).unwrap().is_none(),
|
||||
"logs/.botignore should un-ignore keep.log",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
use dirigent_fermata::core::extract::{extract_path_candidates, Confidence};
|
||||
|
||||
#[test]
|
||||
fn extracts_absolute_unix_path() {
|
||||
let s = "the file is at /home/user/.env and was modified";
|
||||
let cs = extract_path_candidates(s);
|
||||
assert!(cs.iter().any(|c| c.text == "/home/user/.env" && c.confidence == Confidence::High));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_absolute_windows_path() {
|
||||
let s = r"see C:\Users\me\secret.toml for details";
|
||||
let cs = extract_path_candidates(s);
|
||||
assert!(cs.iter().any(|c| c.text == r"C:\Users\me\secret.toml" && c.confidence == Confidence::High));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_relative_with_separator() {
|
||||
let s = "modified src/lib.rs and tests/foo.rs";
|
||||
let cs = extract_path_candidates(s);
|
||||
let texts: Vec<_> = cs.iter().map(|c| c.text.as_str()).collect();
|
||||
assert!(texts.contains(&"src/lib.rs"));
|
||||
assert!(texts.contains(&"tests/foo.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_filename_with_extension_is_low_confidence() {
|
||||
let s = "open README.md please";
|
||||
let cs = extract_path_candidates(s);
|
||||
let r = cs.iter().find(|c| c.text == "README.md").unwrap();
|
||||
assert_eq!(r.confidence, Confidence::Low);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_pure_words() {
|
||||
let s = "the quick brown fox";
|
||||
let cs = extract_path_candidates(s);
|
||||
assert!(cs.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use dirigent_fermata::core::{Decision, Op, Reason, Rule};
|
||||
|
||||
#[test]
|
||||
fn op_variants_exist() {
|
||||
let _ = Op::Read;
|
||||
let _ = Op::Write;
|
||||
let _ = Op::Execute;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_allow_is_simple() {
|
||||
let d = Decision::Allow;
|
||||
assert!(matches!(d, Decision::Allow));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_deny_carries_reason() {
|
||||
let rule = Rule {
|
||||
source: "/proj/.botignore".into(),
|
||||
pattern: ".env".into(),
|
||||
};
|
||||
let d = Decision::Deny(Reason {
|
||||
message: "blocked by .botignore".into(),
|
||||
rule: Some(rule),
|
||||
});
|
||||
match d {
|
||||
Decision::Deny(r) => {
|
||||
assert_eq!(r.message, "blocked by .botignore");
|
||||
assert!(r.rule.is_some());
|
||||
}
|
||||
_ => panic!("expected Deny"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_ask_carries_reason() {
|
||||
let d = Decision::Ask(Reason {
|
||||
message: "needs confirmation".into(),
|
||||
rule: None,
|
||||
});
|
||||
assert!(matches!(d, Decision::Ask(_)));
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use dirigent_fermata::core::{Decision, Policy};
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn project_with(toml: &str) -> TempDir {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
fs::write(tmp.path().join("botignore.toml"), toml).unwrap();
|
||||
tmp
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_substring_blocks() {
|
||||
let tmp = project_with("[bash]\ndeny = [\"rm -rf /\"]\n");
|
||||
let p = Policy::load(tmp.path()).unwrap();
|
||||
assert!(matches!(p.check_command("sudo rm -rf / now").unwrap(), Decision::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_glob_blocks() {
|
||||
let tmp = project_with("[bash]\ndeny = [\"git push --force*\"]\n");
|
||||
let p = Policy::load(tmp.path()).unwrap();
|
||||
assert!(matches!(p.check_command("git push --force-with-lease").unwrap(), Decision::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_returns_ask() {
|
||||
let tmp = project_with("[bash]\nask = [\"rm *\"]\n");
|
||||
let p = Policy::load(tmp.path()).unwrap();
|
||||
assert!(matches!(p.check_command("rm somefile").unwrap(), Decision::Ask(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allow_prefixes_allows() {
|
||||
let tmp = project_with("[bash]\nallow_prefixes = [\"make test\"]\n");
|
||||
let p = Policy::load(tmp.path()).unwrap();
|
||||
assert_eq!(p.check_command("make test").unwrap(), Decision::Allow);
|
||||
assert_eq!(p.check_command("make test-unit").unwrap(), Decision::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_rules_means_allow() {
|
||||
let tmp = project_with("");
|
||||
let p = Policy::load(tmp.path()).unwrap();
|
||||
assert_eq!(p.check_command("anything goes").unwrap(), Decision::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_takes_precedence_over_allow_prefix() {
|
||||
let tmp = project_with("[bash]\ndeny = [\"rm -rf /\"]\nallow_prefixes = [\"rm\"]\n");
|
||||
let p = Policy::load(tmp.path()).unwrap();
|
||||
assert!(matches!(p.check_command("rm -rf /").unwrap(), Decision::Deny(_)));
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
use dirigent_fermata::core::{Decision, Op, Policy};
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_project(botignore: &str, toml_text: &str) -> TempDir {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
fs::write(tmp.path().join(".botignore"), botignore).unwrap();
|
||||
if !toml_text.is_empty() {
|
||||
fs::write(tmp.path().join("botignore.toml"), toml_text).unwrap();
|
||||
}
|
||||
tmp
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn botignore_blocks_read() {
|
||||
let tmp = make_project(".env\n", "");
|
||||
let policy = Policy::load(tmp.path()).unwrap();
|
||||
let target = tmp.path().join(".env");
|
||||
fs::write(&target, "").unwrap();
|
||||
let d = policy.check(Op::Read, &target).unwrap();
|
||||
assert!(matches!(d, Decision::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn botignore_blocks_write_too() {
|
||||
let tmp = make_project(".env\n", "");
|
||||
let policy = Policy::load(tmp.path()).unwrap();
|
||||
let target = tmp.path().join(".env");
|
||||
let d = policy.check(Op::Write, &target).unwrap();
|
||||
assert!(matches!(d, Decision::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmatched_path_allowed() {
|
||||
let tmp = make_project(".env\n", "");
|
||||
let policy = Policy::load(tmp.path()).unwrap();
|
||||
let target = tmp.path().join("src/main.rs");
|
||||
fs::create_dir_all(target.parent().unwrap()).unwrap();
|
||||
fs::write(&target, "").unwrap();
|
||||
let d = policy.check(Op::Read, &target).unwrap();
|
||||
assert_eq!(d, Decision::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_read_block_applies_only_to_read() {
|
||||
let tmp = make_project("", "[read]\npatterns = [\"secrets/**\"]\n");
|
||||
let policy = Policy::load(tmp.path()).unwrap();
|
||||
let target = tmp.path().join("secrets/key.pem");
|
||||
fs::create_dir_all(target.parent().unwrap()).unwrap();
|
||||
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 toml_write_block_applies_only_to_write() {
|
||||
let tmp = make_project("", "[write]\npatterns = [\"vendor/**\"]\n");
|
||||
let policy = Policy::load(tmp.path()).unwrap();
|
||||
let target = tmp.path().join("vendor/lib.rs");
|
||||
fs::create_dir_all(target.parent().unwrap()).unwrap();
|
||||
fs::write(&target, "").unwrap();
|
||||
assert_eq!(policy.check(Op::Read, &target).unwrap(), Decision::Allow);
|
||||
assert!(matches!(policy.check(Op::Write, &target).unwrap(), Decision::Deny(_)));
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
use dirigent_fermata::core::project::find_project_root;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn finds_botignore_toml_first() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::create_dir_all(root.join("sub/deep")).unwrap();
|
||||
fs::write(root.join("botignore.toml"), "").unwrap();
|
||||
fs::write(root.join(".botignore"), "").unwrap();
|
||||
fs::create_dir_all(root.join(".git")).unwrap();
|
||||
|
||||
let target = root.join("sub/deep/file.rs");
|
||||
fs::write(&target, "").unwrap();
|
||||
|
||||
let found = find_project_root(&target).unwrap();
|
||||
assert_eq!(found, root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_botignore() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::create_dir_all(root.join("sub")).unwrap();
|
||||
fs::write(root.join(".botignore"), "").unwrap();
|
||||
|
||||
let target = root.join("sub/file.rs");
|
||||
fs::write(&target, "").unwrap();
|
||||
|
||||
let found = find_project_root(&target).unwrap();
|
||||
assert_eq!(found, root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_git() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::create_dir_all(root.join("sub")).unwrap();
|
||||
fs::create_dir_all(root.join(".git")).unwrap();
|
||||
|
||||
let target = root.join("sub/file.rs");
|
||||
fs::write(&target, "").unwrap();
|
||||
|
||||
let found = find_project_root(&target).unwrap();
|
||||
assert_eq!(found, root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_no_marker() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let target = tmp.path().join("file.rs");
|
||||
std::fs::write(&target, "").unwrap();
|
||||
assert!(find_project_root(&target).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walks_up_from_file_path_not_cwd() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::create_dir_all(root.join("a/b/c")).unwrap();
|
||||
fs::write(root.join("a/.botignore"), "").unwrap();
|
||||
|
||||
let target = root.join("a/b/c/file.rs");
|
||||
fs::write(&target, "").unwrap();
|
||||
|
||||
let found = find_project_root(&target).unwrap();
|
||||
assert_eq!(found, root.join("a"));
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use dirigent_fermata::core::toml_config::{BotignoreToml, OpRules, BashRules};
|
||||
|
||||
#[test]
|
||||
fn parses_full_config() {
|
||||
let src = r#"
|
||||
[read]
|
||||
patterns = [".env*", "secrets/**"]
|
||||
|
||||
[write]
|
||||
patterns = ["vendor/**", "*.lock"]
|
||||
|
||||
[bash]
|
||||
deny = ["rm -rf /", "git push --force*"]
|
||||
ask = ["rm:*"]
|
||||
allow_prefixes = ["make test", "git checkout:*"]
|
||||
"#;
|
||||
let cfg: BotignoreToml = toml::from_str(src).unwrap();
|
||||
assert_eq!(cfg.read.unwrap().patterns, vec![".env*", "secrets/**"]);
|
||||
assert_eq!(cfg.write.unwrap().patterns, vec!["vendor/**", "*.lock"]);
|
||||
let bash = cfg.bash.unwrap();
|
||||
assert_eq!(bash.deny, vec!["rm -rf /", "git push --force*"]);
|
||||
assert_eq!(bash.ask, vec!["rm:*"]);
|
||||
assert_eq!(bash.allow_prefixes, vec!["make test", "git checkout:*"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_config_is_valid() {
|
||||
let cfg: BotignoreToml = toml::from_str("").unwrap();
|
||||
assert!(cfg.read.is_none());
|
||||
assert!(cfg.write.is_none());
|
||||
assert!(cfg.bash.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_from_disk_when_present() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(tmp.path().join("botignore.toml"), "[read]\npatterns = [\".env\"]\n").unwrap();
|
||||
let cfg = BotignoreToml::load(tmp.path()).unwrap();
|
||||
assert_eq!(cfg.read.unwrap().patterns, vec![".env"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_empty_when_missing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cfg = BotignoreToml::load(tmp.path()).unwrap();
|
||||
assert!(cfg.read.is_none());
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Smoke-test contract from `docs/workpad/brainstorm/fermata.md` Appendix A.4.
|
||||
|
||||
use dirigent_fermata::core::{Decision, Op, Policy};
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn fixture() -> TempDir {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::write(root.join(".botignore"), ".env\n.env.*\nconf/cert/**\nconf/mitmproxy/**\n").unwrap();
|
||||
fs::write(
|
||||
root.join("botignore.toml"),
|
||||
r#"
|
||||
[read]
|
||||
patterns = [
|
||||
"conf/localtestsettings.yaml",
|
||||
"conf/localsettings.yaml",
|
||||
"conf/default-secrets.yaml",
|
||||
".claude/self-reflections/**",
|
||||
]
|
||||
|
||||
[write]
|
||||
patterns = [
|
||||
"conf/localtestsettings.yaml",
|
||||
"conf/localsettings.yaml",
|
||||
"conf/default-secrets.yaml",
|
||||
]
|
||||
|
||||
[bash]
|
||||
deny = ["localtestsettings.yaml", "localsettings.yaml", "default-secrets.yaml", ".env"]
|
||||
ask = ["rm *", "mv *"]
|
||||
allow_prefixes = ["make test"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
fs::create_dir_all(root.join("conf")).unwrap();
|
||||
fs::create_dir_all(root.join("datatap")).unwrap();
|
||||
fs::create_dir_all(root.join(".claude/self-reflections")).unwrap();
|
||||
for f in [".env", "conf/localsettings.yaml", "datatap/foo.py", ".claude/self-reflections/x.md"] {
|
||||
fs::write(root.join(f), "").unwrap();
|
||||
}
|
||||
tmp
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_dot_env_denied() {
|
||||
let t = fixture();
|
||||
let p = Policy::load(t.path()).unwrap();
|
||||
assert!(matches!(p.check(Op::Read, &t.path().join(".env")).unwrap(), Decision::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_cat_dot_env_denied() {
|
||||
let t = fixture();
|
||||
let p = Policy::load(t.path()).unwrap();
|
||||
assert!(matches!(p.check_command("cat ./.env").unwrap(), Decision::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_rm_localsettings_denied() {
|
||||
let t = fixture();
|
||||
let p = Policy::load(t.path()).unwrap();
|
||||
assert!(matches!(
|
||||
p.check_command("rm ./conf/localsettings.yaml").unwrap(),
|
||||
Decision::Deny(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_localsettings_denied() {
|
||||
let t = fixture();
|
||||
let p = Policy::load(t.path()).unwrap();
|
||||
assert!(matches!(
|
||||
p.check(Op::Write, &t.path().join("conf/localsettings.yaml")).unwrap(),
|
||||
Decision::Deny(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_datatap_foo_py_allowed() {
|
||||
let t = fixture();
|
||||
let p = Policy::load(t.path()).unwrap();
|
||||
assert_eq!(
|
||||
p.check(Op::Write, &t.path().join("datatap/foo.py")).unwrap(),
|
||||
Decision::Allow
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_make_test_allowed() {
|
||||
let t = fixture();
|
||||
let p = Policy::load(t.path()).unwrap();
|
||||
assert_eq!(p.check_command("make test").unwrap(), Decision::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_rm_somefile_asks() {
|
||||
let t = fixture();
|
||||
let p = Policy::load(t.path()).unwrap();
|
||||
assert!(matches!(p.check_command("rm somefile").unwrap(), Decision::Ask(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_self_reflections_asks() {
|
||||
// Note: A.4 has self-reflections under "ask" — current toml schema uses `[read].patterns`
|
||||
// for hard reads. This documents the gap; once toml has a `[read].ask`, switch to Ask.
|
||||
let t = fixture();
|
||||
let p = Policy::load(t.path()).unwrap();
|
||||
let d = p.check(Op::Read, &t.path().join(".claude/self-reflections/x.md")).unwrap();
|
||||
assert!(matches!(d, Decision::Deny(_)));
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use dirigent_fermata::core::{Decision, Reason};
|
||||
use dirigent_fermata::harness::{HarnessAdapter, PathKind, ToolOp};
|
||||
use dirigent_fermata::harness::claude::ClaudeAdapter;
|
||||
|
||||
#[test]
|
||||
fn parses_read_payload() {
|
||||
let payload = br#"{"tool_name":"Read","tool_input":{"file_path":"/proj/.env"}}"#;
|
||||
let call = ClaudeAdapter.parse_request(payload).unwrap();
|
||||
assert_eq!(call.tool_name, "Read");
|
||||
match call.op {
|
||||
ToolOp::Path { path, kind } => {
|
||||
assert_eq!(path.to_string_lossy(), "/proj/.env");
|
||||
assert_eq!(kind, PathKind::Read);
|
||||
}
|
||||
_ => panic!("expected Path op"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_write_payload() {
|
||||
let payload = br#"{"tool_name":"Write","tool_input":{"file_path":"/proj/out.txt"}}"#;
|
||||
let call = ClaudeAdapter.parse_request(payload).unwrap();
|
||||
assert!(matches!(call.op, ToolOp::Path { kind: PathKind::Write, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_edit_as_write() {
|
||||
let payload = br#"{"tool_name":"Edit","tool_input":{"file_path":"/proj/src.rs"}}"#;
|
||||
let call = ClaudeAdapter.parse_request(payload).unwrap();
|
||||
assert!(matches!(call.op, ToolOp::Path { kind: PathKind::Write, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_multiedit_as_write() {
|
||||
let payload = br#"{"tool_name":"MultiEdit","tool_input":{"file_path":"/proj/src.rs","edits":[]}}"#;
|
||||
let call = ClaudeAdapter.parse_request(payload).unwrap();
|
||||
assert!(matches!(call.op, ToolOp::Path { kind: PathKind::Write, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_bash_payload() {
|
||||
let payload = br#"{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}"#;
|
||||
let call = ClaudeAdapter.parse_request(payload).unwrap();
|
||||
match call.op {
|
||||
ToolOp::Command { text } => assert_eq!(text, "rm -rf /"),
|
||||
_ => panic!("expected Command op"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_deny_as_hookspecificoutput() {
|
||||
let payload = br#"{"tool_name":"Read","tool_input":{"file_path":"/proj/.env"}}"#;
|
||||
let call = ClaudeAdapter.parse_request(payload).unwrap();
|
||||
let d = Decision::Deny(Reason {
|
||||
message: "blocked by .botignore: .env".into(),
|
||||
rule: None,
|
||||
});
|
||||
let out = ClaudeAdapter.render_decision(&call, &d).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
|
||||
assert_eq!(v["hookSpecificOutput"]["hookEventName"], "PreToolUse");
|
||||
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
|
||||
assert!(v["hookSpecificOutput"]["permissionDecisionReason"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains(".env"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_allow_as_allow() {
|
||||
let payload = br#"{"tool_name":"Read","tool_input":{"file_path":"/proj/src/main.rs"}}"#;
|
||||
let call = ClaudeAdapter.parse_request(payload).unwrap();
|
||||
let out = ClaudeAdapter.render_decision(&call, &Decision::Allow).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
|
||||
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "allow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_ask_as_ask() {
|
||||
let payload = br#"{"tool_name":"Bash","tool_input":{"command":"rm something"}}"#;
|
||||
let call = ClaudeAdapter.parse_request(payload).unwrap();
|
||||
let out = ClaudeAdapter
|
||||
.render_decision(&call, &Decision::Ask(Reason { message: "confirm".into(), rule: None }))
|
||||
.unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
|
||||
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "ask");
|
||||
}
|
||||
Reference in New Issue
Block a user