use std::path::{Path, PathBuf};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Off,
On,
Context,
}
impl Mode {
pub fn from_env() -> Self {
Self::parse(std::env::var("SCRYER_HOOK").ok().as_deref())
}
pub fn parse(value: Option<&str>) -> Self {
match value.map(|v| v.trim().to_ascii_lowercase()).as_deref() {
Some("on" | "1" | "true" | "deny") => Mode::On,
Some("context") => Mode::Context,
_ => Mode::Off,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GrepCall {
pub pattern: String,
}
pub fn is_bare_identifier(pattern: &str) -> bool {
let mut chars = pattern.chars();
chars
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& pattern.len() >= 4
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn is_docs_or_tests(text: &str) -> bool {
let lower = text.to_ascii_lowercase();
["test", "doc", "bench", "example", "fixture"]
.iter()
.any(|w| lower.contains(w))
|| [".md", ".txt", ".json", ".yaml", ".yml", ".toml", ".lock"]
.iter()
.any(|ext| lower.contains(ext))
}
fn is_one_file(cwd: &Path, path: &str) -> bool {
cwd.join(path).is_file()
}
fn shell_words(command: &str) -> Option<Vec<String>> {
let mut words = Vec::new();
let mut current = String::new();
let mut in_word = false;
let mut quote: Option<char> = None;
for c in command.chars() {
match quote {
Some(q) if c == q => quote = None,
Some('"') if matches!(c, '$' | '`' | '\\') => return None,
Some(_) => current.push(c),
None => match c {
'\'' | '"' => {
quote = Some(c);
in_word = true;
}
c if c.is_whitespace() => {
if in_word {
words.push(std::mem::take(&mut current));
in_word = false;
}
}
'|' | '&' | ';' | '<' | '>' | '`' | '$' | '(' | ')' | '{' | '}' | '*' | '?'
| '!' | '\\' | '~' | '#' => return None,
c => {
current.push(c);
in_word = true;
}
},
}
}
if quote.is_some() {
return None;
}
if in_word {
words.push(current);
}
Some(words)
}
fn bash_search(command: &str, cwd: &Path) -> Option<GrepCall> {
let words = shell_words(command)?;
let (program, rest) = words.split_first()?;
if program != "grep" && program != "rg" {
return None;
}
let mut positional = Vec::new();
for word in rest {
if let Some(long) = word.strip_prefix("--") {
match long {
"recursive" | "line-number" | "word-regexp" | "with-filename" | "no-messages" => {}
l if l.starts_with("include=") || l.starts_with("exclude-dir=") => {
if is_docs_or_tests(l) {
return None;
}
}
_ => return None,
}
} else if let Some(short) = word.strip_prefix('-').filter(|s| !s.is_empty()) {
if !short.chars().all(|c| "rRnwHsI".contains(c)) {
return None;
}
} else {
positional.push(word.as_str());
}
}
let (pattern, paths) = positional.split_first()?;
if !is_bare_identifier(pattern) {
return None;
}
for path in paths {
if is_docs_or_tests(path) || is_one_file(cwd, path) {
return None;
}
}
Some(GrepCall {
pattern: pattern.to_string(),
})
}
fn grep_tool_search(input: &Value, cwd: &Path) -> Option<GrepCall> {
let flag = |key: &str| input.get(key).and_then(Value::as_bool).unwrap_or(false);
if flag("-i") || flag("multiline") {
return None;
}
let pattern = input.get("pattern")?.as_str()?;
if !is_bare_identifier(pattern) {
return None;
}
if let Some(path) = input.get("path").and_then(Value::as_str)
&& (is_docs_or_tests(path) || is_one_file(cwd, path))
{
return None;
}
for key in ["glob", "type"] {
if let Some(value) = input.get(key).and_then(Value::as_str)
&& (is_docs_or_tests(value) || matches!(value, "md" | "json" | "yaml" | "toml" | "txt"))
{
return None;
}
}
Some(GrepCall {
pattern: pattern.to_string(),
})
}
pub fn classify(event: &Value) -> Option<GrepCall> {
let cwd = event
.get("cwd")
.and_then(Value::as_str)
.map_or_else(|| PathBuf::from("."), PathBuf::from);
let input = event.get("tool_input")?;
match event.get("tool_name")?.as_str()? {
"Grep" => grep_tool_search(input, &cwd),
"Bash" => bash_search(input.get("command")?.as_str()?, &cwd),
_ => None,
}
}
pub fn names_one_definition(inspected: &Value) -> bool {
inspected["found"] == true
&& inspected.get("ambiguous").and_then(Value::as_bool) != Some(true)
&& inspected.get("is_external").and_then(Value::as_bool) != Some(true)
&& inspected["kind"].as_str().is_some_and(|kind| {
matches!(
kind,
"fn" | "function"
| "method"
| "struct"
| "enum"
| "trait"
| "type"
| "class"
| "interface"
)
})
}
pub fn deny_output(pattern: &str, answer: &str) -> String {
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": format!(
"Scryer answered this grep for `{pattern}` from its index, so it did not run:\n{answer}\nTo get the raw grep output, repeat the same search."
),
}
})
.to_string()
}
pub fn context_output(pattern: &str, answer: &str) -> String {
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext": format!("Scryer's answer for `{pattern}` from its index:\n{answer}"),
}
})
.to_string()
}
pub struct SeenStore {
path: PathBuf,
}
impl SeenStore {
pub fn for_session(session_id: &str) -> Self {
let dir = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir)
.join("scryer");
let safe: String = session_id
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
Self {
path: dir.join(format!("hook-{safe}.json")),
}
}
#[cfg(test)]
pub fn at(path: PathBuf) -> Self {
Self { path }
}
fn patterns(&self) -> Vec<String> {
std::fs::read_to_string(&self.path)
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_default()
}
pub fn seen(&self, pattern: &str) -> bool {
self.patterns().iter().any(|p| p == pattern)
}
pub fn record(&self, pattern: &str) {
let mut patterns = self.patterns();
patterns.push(pattern.to_string());
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(
&self.path,
serde_json::to_string(&patterns).unwrap_or_default(),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn grep(pattern: &str) -> Value {
json!({"tool_name": "Grep", "tool_input": {"pattern": pattern}, "cwd": "/nonexistent"})
}
fn bash(command: &str) -> Value {
json!({"tool_name": "Bash", "tool_input": {"command": command}, "cwd": "/nonexistent"})
}
fn pattern(event: &Value) -> Option<String> {
classify(event).map(|c| c.pattern)
}
#[test]
fn only_a_bare_identifier_of_four_or_more_characters_is_answered() {
assert_eq!(
pattern(&grep("index_project")).as_deref(),
Some("index_project")
);
assert_eq!(pattern(&grep("Widget")).as_deref(), Some("Widget"));
for not in [
"foo", "foo.*bar", "fn index_project", "index-project",
"\"quoted\"",
"src/main.rs",
"9lives",
"",
] {
assert_eq!(pattern(&grep(not)), None, "{not}");
}
}
#[test]
fn a_search_narrowed_to_docs_tests_or_one_file_is_left_alone() {
let with = |key: &str, value: Value| {
json!({"tool_name": "Grep", "cwd": "/nonexistent",
"tool_input": {"pattern": "index_project", key: value}})
};
assert_eq!(pattern(&with("path", json!("docs/"))), None);
assert_eq!(pattern(&with("path", json!("crates/x/tests"))), None);
assert_eq!(pattern(&with("glob", json!("*.md"))), None);
assert_eq!(pattern(&with("glob", json!("**/*_test.rs"))), None);
assert_eq!(pattern(&with("type", json!("md"))), None);
assert_eq!(pattern(&with("-i", json!(true))), None);
assert_eq!(pattern(&with("multiline", json!(true))), None);
assert!(pattern(&with("path", json!("crates/"))).is_some());
assert!(pattern(&with("glob", json!("*.rs"))).is_some());
assert!(pattern(&with("type", json!("rust"))).is_some());
}
#[test]
fn a_search_of_one_existing_file_is_left_alone() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.rs"), "fn index_project() {}").unwrap();
let event = json!({"tool_name": "Grep", "cwd": dir.path(),
"tool_input": {"pattern": "index_project", "path": "a.rs"}});
assert_eq!(pattern(&event), None);
let event = json!({"tool_name": "Bash", "cwd": dir.path(),
"tool_input": {"command": "grep -n index_project a.rs"}});
assert_eq!(pattern(&event), None);
}
#[test]
fn plain_grep_and_rg_commands_are_answered() {
assert_eq!(
pattern(&bash("grep -rn index_project .")).as_deref(),
Some("index_project")
);
assert_eq!(
pattern(&bash("rg index_project src")).as_deref(),
Some("index_project")
);
assert_eq!(
pattern(&bash("grep -rnw 'index_project' crates")).as_deref(),
Some("index_project")
);
assert_eq!(
pattern(&bash("grep -r --include=\"*.rs\" index_project .")).as_deref(),
Some("index_project")
);
}
#[test]
fn anything_else_in_a_bash_command_is_never_intercepted() {
for command in [
"grep -rn index_project . | head",
"grep -rn index_project . && ls",
"grep -rn index_project . > out.txt",
"grep -rn $NAME .",
"grep -rn \"$(echo x)\" .",
"grep -rn index_project *.rs",
"grep -rni index_project .", "grep -rnl index_project .", "grep -rn -e index_project .", "grep -rn 'index.*project' .",
"grep -rn \"unterminated .",
"cat index_project",
"git grep index_project",
"ls",
"",
] {
assert_eq!(pattern(&bash(command)), None, "{command}");
}
}
#[test]
fn other_tools_are_ignored() {
let event = json!({"tool_name": "Read", "tool_input": {"file_path": "x.rs"}});
assert_eq!(classify(&event), None);
}
#[test]
fn the_mode_comes_from_the_environment_and_defaults_to_off() {
assert_eq!(Mode::parse(None), Mode::Off);
assert_eq!(Mode::parse(Some("")), Mode::Off);
assert_eq!(Mode::parse(Some("off")), Mode::Off);
assert_eq!(Mode::parse(Some("garbage")), Mode::Off);
assert_eq!(Mode::parse(Some("on")), Mode::On);
assert_eq!(Mode::parse(Some("ON")), Mode::On);
assert_eq!(Mode::parse(Some("context")), Mode::Context);
}
#[test]
fn exactly_one_function_or_type_qualifies() {
assert!(names_one_definition(&json!({"found": true, "kind": "fn"})));
assert!(names_one_definition(
&json!({"found": true, "kind": "struct"})
));
assert!(!names_one_definition(&json!({"found": false})));
assert!(!names_one_definition(
&json!({"found": true, "ambiguous": true, "kind": "fn"})
));
assert!(!names_one_definition(
&json!({"found": true, "kind": "const"})
));
assert!(!names_one_definition(
&json!({"found": true, "kind": "fn", "is_external": true})
));
}
#[test]
fn a_pattern_is_denied_once_per_session() {
let dir = tempfile::tempdir().unwrap();
let store = SeenStore::at(dir.path().join("s/hook-a.json"));
assert!(!store.seen("index_project"));
store.record("index_project");
assert!(store.seen("index_project"));
assert!(!store.seen("other_name"));
}
#[test]
fn the_deny_output_is_what_claude_code_expects() {
let out: Value =
serde_json::from_str(&deny_output("index_project", "callers: a, b")).unwrap();
let specific = &out["hookSpecificOutput"];
assert_eq!(specific["hookEventName"], "PreToolUse");
assert_eq!(specific["permissionDecision"], "deny");
let reason = specific["permissionDecisionReason"].as_str().unwrap();
assert!(reason.contains("callers: a, b"));
assert!(reason.contains("repeat the same search"));
let out: Value = serde_json::from_str(&context_output("index_project", "x")).unwrap();
assert!(
out["hookSpecificOutput"]
.get("permissionDecision")
.is_none()
);
assert!(out["hookSpecificOutput"]["additionalContext"].is_string());
}
}