use crate::decision::{Decision, DecisionKind, Severity};
use crate::facts::Facts;
use crate::hook_input::HookInput;
use crate::reason;
use super::ConfigRule;
pub struct SensitiveRead;
const RULE_ID: &str = "core.secrets.sensitive-read";
impl ConfigRule for SensitiveRead {
fn id(&self) -> &str {
RULE_ID
}
fn severity(&self) -> Severity {
Severity::High
}
fn default_decision(&self) -> DecisionKind {
DecisionKind::Deny
}
fn hard_deny(&self) -> bool {
true
}
fn evaluate(&self, facts: &Facts, input: &HookInput) -> Option<Decision> {
let is_file_tool = matches!(
input.tool_name.as_str(),
"Read" | "Edit" | "Write" | "apply_patch",
) || (input.is_mcp_tool() && !facts.paths.is_empty());
if !is_file_tool {
return None;
}
if facts.sensitive.is_empty() {
return None;
}
let reason = reason::build(
RULE_ID,
"The requested file looks like a credentials store (SSH key, AWS / gcloud / kube \
config, dotenv, npmrc, pypirc, tfstate, or PEM blob). Even reading it through the \
agent exposes the secret to the model and tool transcript.",
&[
"Ask the user to inspect or transform the file themselves.",
"Operate on a redacted copy with the secret values stripped.",
"If you only need a structural sample, point the user at a synthetic example.",
],
);
Some(Decision::Deny {
rule_id: RULE_ID.into(),
reason,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn read(file_path: &str) -> HookInput {
HookInput {
tool_name: "Read".into(),
tool_input: serde_json::json!({ "file_path": file_path }),
}
}
fn edit(file_path: &str, new_string: &str) -> HookInput {
HookInput {
tool_name: "Edit".into(),
tool_input: serde_json::json!({
"file_path": file_path,
"new_string": new_string,
}),
}
}
#[test]
fn denies_read_of_ssh_key() {
let input = read("~/.ssh/id_ed25519");
let facts = crate::facts::extract(&input);
let d = SensitiveRead.evaluate(&facts, &input);
assert!(matches!(
d,
Some(Decision::Deny { ref rule_id, .. }) if rule_id == RULE_ID
));
}
#[test]
fn denies_edit_of_dotenv() {
let input = edit("/repo/.env.production", "API_KEY=value");
let facts = crate::facts::extract(&input);
let d = SensitiveRead.evaluate(&facts, &input);
assert!(matches!(d, Some(Decision::Deny { .. })));
}
#[test]
fn denies_read_of_aws_credentials() {
let input = read("~/.aws/credentials");
let facts = crate::facts::extract(&input);
assert!(matches!(
SensitiveRead.evaluate(&facts, &input),
Some(Decision::Deny { .. })
));
}
#[test]
fn denies_read_of_absolute_kube_config() {
let input = read("/home/alice/.kube/config");
let facts = crate::facts::extract(&input);
assert!(matches!(
SensitiveRead.evaluate(&facts, &input),
Some(Decision::Deny { ref rule_id, .. }) if rule_id == RULE_ID,
));
}
#[test]
fn allows_read_of_non_sensitive_file() {
let input = read("/repo/src/main.rs");
let facts = crate::facts::extract(&input);
assert!(SensitiveRead.evaluate(&facts, &input).is_none());
}
#[test]
fn does_not_fire_for_bash_invocations_pointing_at_secret_paths() {
let input = HookInput {
tool_name: "Bash".into(),
tool_input: serde_json::json!({ "command": "cat ~/.ssh/id_rsa" }),
};
let facts = crate::facts::extract(&input);
assert!(SensitiveRead.evaluate(&facts, &input).is_none());
}
#[test]
fn does_not_fire_for_non_sensitive_write() {
let input = HookInput {
tool_name: "Write".into(),
tool_input: serde_json::json!({
"file_path": "/repo/src/lib.rs",
"content": "fn main() {}",
}),
};
let facts = crate::facts::extract(&input);
assert!(SensitiveRead.evaluate(&facts, &input).is_none());
}
#[test]
fn denies_write_of_dotenv() {
let input = HookInput {
tool_name: "Write".into(),
tool_input: serde_json::json!({
"file_path": ".env",
"content": "API_KEY=value",
}),
};
let facts = crate::facts::extract(&input);
assert!(matches!(
SensitiveRead.evaluate(&facts, &input),
Some(Decision::Deny { ref rule_id, .. }) if rule_id == RULE_ID,
));
}
#[test]
fn denies_write_of_pem_content_to_arbitrary_path() {
let input = HookInput {
tool_name: "Write".into(),
tool_input: serde_json::json!({
"file_path": "/repo/foo.md",
"content": "-----BEGIN RSA PRIVATE KEY-----\nXYZ\n-----END RSA PRIVATE KEY-----",
}),
};
let facts = crate::facts::extract(&input);
assert!(matches!(
SensitiveRead.evaluate(&facts, &input),
Some(Decision::Deny { .. }),
));
}
#[test]
fn denies_apply_patch_adding_dotenv() {
let input = HookInput {
tool_name: "apply_patch".into(),
tool_input: serde_json::json!({
"command": "*** Begin Patch\n*** Add File: .env\n+API_KEY=value\n*** End Patch\n",
}),
};
let facts = crate::facts::extract(&input);
assert!(matches!(
SensitiveRead.evaluate(&facts, &input),
Some(Decision::Deny { ref rule_id, .. }) if rule_id == RULE_ID,
));
}
#[test]
fn does_not_fire_for_non_sensitive_apply_patch() {
let input = HookInput {
tool_name: "apply_patch".into(),
tool_input: serde_json::json!({
"command": "*** Begin Patch\n*** Add File: src/lib.rs\n+fn main() {}\n\
*** End Patch\n",
}),
};
let facts = crate::facts::extract(&input);
assert!(SensitiveRead.evaluate(&facts, &input).is_none());
}
#[test]
fn metadata_matches_design() {
assert!(SensitiveRead.hard_deny());
assert_eq!(SensitiveRead.severity(), Severity::High);
assert_eq!(SensitiveRead.default_decision(), DecisionKind::Deny);
assert_eq!(SensitiveRead.id(), RULE_ID);
}
#[test]
fn denies_mcp_filesystem_read_of_aws_credentials() {
let input = HookInput {
tool_name: "mcp__filesystem__read_file".into(),
tool_input: serde_json::json!({"path": "~/.aws/credentials"}),
};
let facts = crate::facts::extract(&input);
assert!(matches!(
SensitiveRead.evaluate(&facts, &input),
Some(Decision::Deny { .. })
));
}
#[test]
fn denies_mcp_github_create_or_update_of_pem_path() {
let input = HookInput {
tool_name: "mcp__github__create_or_update_file".into(),
tool_input: serde_json::json!({"path": "~/.ssh/id_rsa", "content": "secret"}),
};
let facts = crate::facts::extract(&input);
assert!(matches!(
SensitiveRead.evaluate(&facts, &input),
Some(Decision::Deny { .. })
));
}
#[test]
fn does_not_fire_for_mcp_tool_without_a_path_field() {
let input = HookInput {
tool_name: "mcp__fetch__fetch".into(),
tool_input: serde_json::json!({"url": "https://example.com/x"}),
};
let facts = crate::facts::extract(&input);
assert!(SensitiveRead.evaluate(&facts, &input).is_none());
}
use crate::testing::proptest::{file_path, richer_hook_input};
use proptest::prelude::*;
proptest! {
#[test]
fn pbt_non_file_tool_yields_none(
tool in "[A-Z][A-Za-z]{0,8}",
fp in file_path(),
) {
prop_assume!(!matches!(tool.as_str(), "Read" | "Edit" | "Write"));
let input = HookInput {
tool_name: tool,
tool_input: serde_json::json!({ "file_path": fp }),
};
let facts = crate::facts::extract(&input);
prop_assert!(SensitiveRead.evaluate(&facts, &input).is_none());
}
#[test]
fn pbt_empty_sensitive_yields_none(input in richer_hook_input()) {
let facts = crate::facts::Facts::default();
prop_assert!(SensitiveRead.evaluate(&facts, &input).is_none());
}
#[test]
fn pbt_evaluate_never_panics(input in richer_hook_input()) {
let facts = crate::facts::extract(&input);
let _ = SensitiveRead.evaluate(&facts, &input);
}
#[test]
fn pbt_only_emits_deny_with_correct_id(input in richer_hook_input()) {
let facts = crate::facts::extract(&input);
if let Some(d) = SensitiveRead.evaluate(&facts, &input) {
match d {
Decision::Deny { rule_id, .. } => prop_assert_eq!(rule_id, RULE_ID),
other => prop_assert!(false, "expected Deny, got {other:?}"),
}
}
}
#[test]
fn pbt_sensitive_read_paths_always_fire(
tool in proptest::sample::select(&["Read", "Edit", "Write"][..]),
sensitive_fp in proptest::sample::select(
&[
"~/.ssh/id_rsa",
"~/.ssh/id_ed25519",
"~/.aws/credentials",
"~/.kube/config",
"~/.docker/config.json",
".env",
".env.production",
"infra/main.tfstate",
".npmrc",
".pypirc",
][..],
),
) {
let input = HookInput {
tool_name: tool.to_string(),
tool_input: serde_json::json!({ "file_path": sensitive_fp }),
};
let facts = crate::facts::extract(&input);
let d = SensitiveRead.evaluate(&facts, &input);
prop_assert!(
matches!(d, Some(Decision::Deny { ref rule_id, .. }) if rule_id == RULE_ID),
"expected Deny for {sensitive_fp:?}, got {d:?}",
);
}
}
}