087429d275
Implement Goals 1–3 and 5 from the reveal-layer security brain goal.
fermata now detects, redacts, and scans for secrets in AI agent tool
output, filling the ecosystem gap where no coding agent filters secrets
post-read.
New core/secrets/ module:
- config.rs: .botsecrets TOML format with hierarchical merge and ~40
built-in key patterns
- parser.rs: multi-format secret file parser (.env, TOML, YAML, JSON,
Python assignments, Java properties)
- manifest.rs: file discovery + parsing → known-secrets set
- redactor.rs: Aho-Corasick multi-pattern replacement with 4 styles
- scanner.rs: RegexSet heuristic detection with 35 gitleaks-derived
patterns (MIT) and Shannon entropy filtering
- patterns.rs: curated rules for AWS, GitHub, Stripe, Slack, JWT, etc.
Hook integration:
- fermata hook --event post-tool-use reads tool output, runs redactor +
scanner, returns updatedToolOutput for Claude Code
- Backward compatible: --event pre-tool-use (default) unchanged
- Fail-open: errors produce {} and exit 0
Library API:
- Redactor::new(manifest, style).redact(text) → RedactedText
- Scanner::new(config).scan(text) → Vec<Finding>
- Compiles without CLI feature for embedding in other crates
195 tests (130 new), all passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
144 lines
5.6 KiB
Rust
144 lines
5.6 KiB
Rust
use dirigent_fermata::core::{Decision, Reason};
|
|
use dirigent_fermata::harness::{HarnessAdapter, HookEvent, 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");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PostToolUse
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn parses_post_tool_use_payload() {
|
|
let payload = br#"{"tool_name":"Read","tool_input":{"file_path":"/proj/.env"},"tool_response":"SECRET=abc"}"#;
|
|
let p = ClaudeAdapter.parse_post_tool_use(payload).unwrap();
|
|
assert_eq!(p.tool_name, "Read");
|
|
assert_eq!(p.tool_response, "SECRET=abc");
|
|
}
|
|
|
|
#[test]
|
|
fn parses_post_tool_use_missing_response() {
|
|
// tool_response absent → defaults to empty string.
|
|
let payload = br#"{"tool_name":"Bash","tool_input":{"command":"ls"}}"#;
|
|
let p = ClaudeAdapter.parse_post_tool_use(payload).unwrap();
|
|
assert_eq!(p.tool_response, "");
|
|
}
|
|
|
|
#[test]
|
|
fn renders_post_tool_use_with_redacted_output() {
|
|
let payload = br#"{"tool_name":"Read","tool_input":{},"tool_response":"x"}"#;
|
|
let p = ClaudeAdapter.parse_post_tool_use(payload).unwrap();
|
|
let out = ClaudeAdapter
|
|
.render_post_tool_use(&p, Some("redacted text"))
|
|
.unwrap();
|
|
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
|
|
assert_eq!(v["hookSpecificOutput"]["hookEventName"], "PostToolUse");
|
|
assert_eq!(
|
|
v["hookSpecificOutput"]["updatedToolOutput"],
|
|
"redacted text"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn renders_post_tool_use_passthrough() {
|
|
let payload = br#"{"tool_name":"Read","tool_input":{},"tool_response":"clean"}"#;
|
|
let p = ClaudeAdapter.parse_post_tool_use(payload).unwrap();
|
|
let out = ClaudeAdapter.render_post_tool_use(&p, None).unwrap();
|
|
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
|
|
assert_eq!(v, serde_json::json!({}));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HookEvent parsing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn hook_event_parse_variants() {
|
|
assert_eq!(HookEvent::parse("pre-tool-use"), Some(HookEvent::PreToolUse));
|
|
assert_eq!(HookEvent::parse("PreToolUse"), Some(HookEvent::PreToolUse));
|
|
assert_eq!(HookEvent::parse("post-tool-use"), Some(HookEvent::PostToolUse));
|
|
assert_eq!(HookEvent::parse("PostToolUse"), Some(HookEvent::PostToolUse));
|
|
assert_eq!(HookEvent::parse("unknown"), None);
|
|
}
|