use serde_json::{json, Value};
use std::path::Path;
use std::time::{Duration, SystemTime};
const REGEX_SIZE_LIMIT: usize = 10 * 1024 * 1024;
const GREP_FOOTER_FRESHNESS_WINDOW: Duration = Duration::from_secs(60);
use crate::bash_rewrite::footer::{add_footer, add_grep_footer};
use crate::bash_rewrite::parser::parse;
use crate::bash_rewrite::{RewriteRequest, RewriteRule};
use crate::context::AppContext;
use crate::protocol::{RawRequest, Response};
pub struct GrepRule;
pub struct RgRule;
pub struct FindRule;
pub struct CatRule;
pub struct CatAppendRule;
pub struct SedRule;
pub struct LsRule;
impl RewriteRule for GrepRule {
fn name(&self) -> &'static str {
"grep"
}
fn decide(
&self,
command: &str,
request_id: &str,
session_id: Option<&str>,
ctx: &AppContext,
) -> crate::bash_rewrite::RewriteDecision {
let Some(params) = grep_request(command, "grep") else {
return decline("grep", "grep.decline", "unsupported grep shape");
};
if let Some(path) = params.get("path").and_then(Value::as_str) {
if !path_is_safe(ctx, path, true) {
return decline(
"grep",
"grep.decline",
"grep path is outside the project root or missing",
);
}
}
accept(
"grep",
"grep.accept",
"dc.grep.accept.v1",
command,
request_id,
session_id,
params,
)
}
fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
let path = request.params.get("path").and_then(Value::as_str);
let response = crate::commands::grep::handle_grep(&tool_request("grep", request, ctx), ctx);
grep_footer_response(response, ctx, path)
}
}
impl RewriteRule for RgRule {
fn name(&self) -> &'static str {
"rg"
}
fn decide(
&self,
command: &str,
request_id: &str,
session_id: Option<&str>,
ctx: &AppContext,
) -> crate::bash_rewrite::RewriteDecision {
let Some(params) = grep_request(command, "rg") else {
return decline("rg", "rg.decline", "unsupported rg shape");
};
if let Some(path) = params.get("path").and_then(Value::as_str) {
if !path_is_safe(ctx, path, true) {
return decline(
"rg",
"rg.decline",
"rg path is outside the project root or missing",
);
}
}
accept(
"rg",
"rg.accept",
"dc.rg.accept.v1",
command,
request_id,
session_id,
params,
)
}
fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
let path = request.params.get("path").and_then(Value::as_str);
let response = crate::commands::grep::handle_grep(&tool_request("grep", request, ctx), ctx);
grep_footer_response(response, ctx, path)
}
}
impl RewriteRule for FindRule {
fn name(&self) -> &'static str {
"find"
}
fn decide(
&self,
command: &str,
request_id: &str,
session_id: Option<&str>,
ctx: &AppContext,
) -> crate::bash_rewrite::RewriteDecision {
let Some(params) = find_request(command) else {
return decline("find", "find.decline", "unsupported find shape");
};
if let Some(path) = params.get("path").and_then(Value::as_str) {
if !path_is_safe(ctx, path, true) {
return decline(
"find",
"find.decline",
"find path is outside the project root or missing",
);
}
}
accept(
"find",
"find.accept",
"dc.find.accept.v1",
command,
request_id,
session_id,
params,
)
}
fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
call_and_footer(
crate::commands::glob::handle_glob(&tool_request("glob", request, ctx), ctx),
"glob",
)
}
}
impl RewriteRule for CatRule {
fn name(&self) -> &'static str {
"cat"
}
fn decide(
&self,
command: &str,
request_id: &str,
session_id: Option<&str>,
ctx: &AppContext,
) -> crate::bash_rewrite::RewriteDecision {
let Some(params) = cat_read_request(command) else {
return decline("cat", "cat.decline", "unsupported cat shape");
};
let path = params
.get("file")
.and_then(Value::as_str)
.unwrap_or_default();
if !path_is_safe(ctx, path, true) || !read_shape_is_faithful(ctx, path) {
crate::slog_warn!(
"bash rewrite rule cat declined: read declined: path is outside the project root or exceeds the read contract"
);
return decline(
"cat",
"cat.decline",
"read path is outside the project root or exceeds the read contract",
);
}
accept(
"cat",
"cat.accept",
"dc.cat.accept.v1",
command,
request_id,
session_id,
params,
)
}
fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
call_and_footer(
crate::commands::read::handle_read(&tool_request("read", request, ctx), ctx),
"read",
)
}
}
impl RewriteRule for CatAppendRule {
fn name(&self) -> &'static str {
"cat_append"
}
fn decide(
&self,
command: &str,
request_id: &str,
session_id: Option<&str>,
ctx: &AppContext,
) -> crate::bash_rewrite::RewriteDecision {
let Some(params) = append_request(command) else {
return decline(
"cat_append",
"cat_append.decline",
"unsupported append shape",
);
};
let path = params
.get("file")
.and_then(Value::as_str)
.unwrap_or_default();
if !append_path_is_safe(ctx, path) {
return decline(
"cat_append",
"cat_append.decline",
"append path is outside the project root or has no existing parent",
);
}
accept(
"cat_append",
"cat_append.accept",
"dc.cat_append.accept.v1",
command,
request_id,
session_id,
params,
)
}
fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
call_and_footer(
crate::commands::edit_match::handle_edit_match(
&tool_request("edit_match", request, ctx),
ctx,
),
"edit",
)
}
}
impl RewriteRule for SedRule {
fn name(&self) -> &'static str {
"sed"
}
fn decide(
&self,
command: &str,
request_id: &str,
session_id: Option<&str>,
ctx: &AppContext,
) -> crate::bash_rewrite::RewriteDecision {
let Some(params) = sed_request(command) else {
return decline("sed", "sed.decline", "unsupported sed shape");
};
let path = params
.get("file")
.and_then(Value::as_str)
.unwrap_or_default();
if !path_is_safe(ctx, path, true) || !read_shape_is_faithful(ctx, path) {
return decline(
"sed",
"sed.decline",
"sed path is outside the project root or exceeds the read contract",
);
}
accept(
"sed",
"sed.accept",
"dc.sed.accept.v1",
command,
request_id,
session_id,
params,
)
}
fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
call_and_footer(
crate::commands::read::handle_read(&tool_request("read", request, ctx), ctx),
"read",
)
}
}
impl RewriteRule for LsRule {
fn name(&self) -> &'static str {
"ls"
}
fn decide(
&self,
command: &str,
request_id: &str,
session_id: Option<&str>,
ctx: &AppContext,
) -> crate::bash_rewrite::RewriteDecision {
let Some(params) = ls_request(command, ctx) else {
return decline("ls", "ls.decline", "unsupported ls shape or target");
};
accept(
"ls",
"ls.accept",
"dc.ls.accept.v1",
command,
request_id,
session_id,
params,
)
}
fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
call_and_footer(
crate::commands::read::handle_read(&tool_request("read", request, ctx), ctx),
"read",
)
}
}
fn accept(
rule_id: &'static str,
branch_id: &'static str,
decision_class_id: &'static str,
command: &str,
request_id: &str,
session_id: Option<&str>,
params: Value,
) -> crate::bash_rewrite::RewriteDecision {
crate::bash_rewrite::RewriteDecision::Accept(RewriteRequest {
request_id: request_id.to_string(),
command: command.to_string(),
session_id: session_id.map(str::to_owned),
rule_id,
branch_id,
decision_class_id,
params,
})
}
fn decline(
rule_id: &'static str,
branch_id: &'static str,
reason: &str,
) -> crate::bash_rewrite::RewriteDecision {
let decision_class_id = match rule_id {
"grep" => "dc.grep.decline.v1",
"rg" => "dc.rg.decline.v1",
"find" => "dc.find.decline.v1",
"cat" => "dc.cat.decline.v1",
"cat_append" => "dc.cat_append.decline.v1",
"sed" => "dc.sed.decline.v1",
"ls" => "dc.ls.decline.v1",
_ => "dc.native.decline.v1",
};
crate::bash_rewrite::RewriteDecision::Decline(crate::bash_rewrite::DeclineReason {
rule_id: Some(rule_id),
branch_id,
decision_class_id,
reason: reason.to_string(),
})
}
fn tool_request(tool: &str, request: &RewriteRequest, ctx: &AppContext) -> RawRequest {
let mut params = request.params.clone();
let root = grep_project_root(ctx);
if matches!(tool, "read" | "edit_match") {
if let Some(file) = params.get("file").and_then(Value::as_str) {
let path = Path::new(file);
if path.is_relative() {
params["file"] = Value::String(root.join(path).display().to_string());
}
}
}
if tool == "glob" && params.get("path").is_none() {
params["path"] = Value::String(root.display().to_string());
}
RawRequest {
id: request.request_id.clone(),
command: tool.to_string(),
lsp_hints: None,
session_id: request.session_id.clone(),
params,
}
}
fn call_and_footer(response: Response, replacement_tool: &str) -> Response {
let output = response_output(&response.data);
let footered = add_footer(&output, replacement_tool);
apply_footer(response, footered)
}
fn grep_footer_response(response: Response, ctx: &AppContext, path: Option<&str>) -> Response {
let output = response_output(&response.data);
let footered = if should_suppress_grep_footer(path, &grep_project_root(ctx)) {
output
} else {
add_grep_footer(&output, ctx.config().aft_search_registered)
};
apply_footer(response, footered)
}
fn grep_project_root(ctx: &AppContext) -> std::path::PathBuf {
let configured = ctx
.config()
.project_root
.clone()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
std::fs::canonicalize(&configured).unwrap_or(configured)
}
fn should_suppress_grep_footer(path: Option<&str>, project_root: &Path) -> bool {
let Some(path) = path else {
return false;
};
let project_root =
std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
let project_root = project_root.as_path();
let target = Path::new(path);
let target = if target.is_absolute() {
target.to_path_buf()
} else {
project_root.join(target)
};
let Ok(target) = std::fs::canonicalize(target) else {
return false;
};
if !target.starts_with(project_root) {
return true;
}
let Ok(metadata) = std::fs::metadata(&target) else {
return false;
};
if metadata.is_file() {
return true;
}
let Ok(modified) = metadata.modified() else {
return false;
};
SystemTime::now()
.duration_since(modified)
.is_ok_and(|age| age < GREP_FOOTER_FRESHNESS_WINDOW)
}
fn apply_footer(mut response: Response, output: String) -> Response {
if let Some(object) = response.data.as_object_mut() {
object.insert("output".to_string(), Value::String(output.clone()));
for key in ["text", "content", "message"] {
if object.get(key).is_some_and(Value::is_string) {
object.insert(key.to_string(), Value::String(output.clone()));
break;
}
}
} else {
response.data = json!({ "output": output });
}
response
}
fn response_output(data: &Value) -> String {
if let Some(output) = data.get("output").and_then(Value::as_str) {
return output.to_string();
}
if let Some(text) = data.get("text").and_then(Value::as_str) {
return text.to_string();
}
if let Some(content) = data.get("content").and_then(Value::as_str) {
return content.to_string();
}
if let Some(message) = data.get("message").and_then(Value::as_str) {
return message.to_string();
}
if let Some(entries) = data.get("entries").and_then(Value::as_array) {
return entries
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join("\n");
}
serde_json::to_string_pretty(data).unwrap_or_else(|_| data.to_string())
}
fn path_is_safe(ctx: &AppContext, path: &str, require_existing: bool) -> bool {
let root = grep_project_root(ctx);
let candidate = Path::new(path);
let candidate = if candidate.is_absolute() {
candidate.to_path_buf()
} else {
root.join(candidate)
};
if require_existing && !candidate.exists() {
return false;
}
let resolved = std::fs::canonicalize(&candidate).unwrap_or(candidate);
resolved.starts_with(&root)
}
fn read_shape_is_faithful(ctx: &AppContext, path: &str) -> bool {
let root = grep_project_root(ctx);
let candidate = if Path::new(path).is_absolute() {
Path::new(path).to_path_buf()
} else {
root.join(path)
};
let Ok(metadata) = std::fs::metadata(&candidate) else {
return false;
};
if !metadata.is_file() || metadata.len() > 50 * 1024 {
return false;
}
let Ok(bytes) = std::fs::read(candidate) else {
return false;
};
let Ok(text) = std::str::from_utf8(&bytes) else {
return false;
};
text.lines().all(|line| line.len() <= 2_000)
}
fn append_path_is_safe(ctx: &AppContext, path: &str) -> bool {
let root = grep_project_root(ctx);
let candidate = if Path::new(path).is_absolute() {
Path::new(path).to_path_buf()
} else {
root.join(path)
};
let parent = candidate.parent().unwrap_or(&root);
if !parent.exists() {
return false;
}
let resolved_parent = std::fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf());
if !resolved_parent.starts_with(&root) {
return false;
}
if candidate.exists() {
let Ok(resolved) = std::fs::canonicalize(&candidate) else {
return false;
};
resolved.starts_with(&root)
} else {
true
}
}
fn grep_request(command: &str, binary: &str) -> Option<Value> {
let parsed = parse(command)?;
if parsed.appends_to.is_some() || parsed.heredoc.is_some() || parsed.args.first()? != binary {
return None;
}
let mut case_sensitive = true;
let mut word_match = false;
let mut index = 1;
while let Some(arg) = parsed.args.get(index) {
if !arg.starts_with('-') || arg == "-" {
break;
}
for flag in arg[1..].chars() {
match flag {
'n' | 'r' => {}
'i' => case_sensitive = false,
'w' => word_match = true,
_ => return None,
}
}
index += 1;
}
let pattern = parsed.args.get(index)?.clone();
let path = parsed.args.get(index + 1).cloned();
if parsed.args.len() > index + 2 {
return None;
}
let pattern = if word_match {
format!(r"\b(?:{})\b", pattern)
} else {
pattern
};
if regex::RegexBuilder::new(&pattern)
.size_limit(REGEX_SIZE_LIMIT)
.build()
.is_err()
{
return None;
}
let mut params = json!({
"pattern": pattern,
"case_sensitive": case_sensitive,
"max_results": 100,
});
if let Some(path) = path {
params["path"] = json!(path);
}
Some(params)
}
fn find_request(command: &str) -> Option<Value> {
let parsed = parse(command)?;
if parsed.appends_to.is_some() || parsed.heredoc.is_some() || parsed.args.first()? != "find" {
return None;
}
if parsed.args.len() != 4 && parsed.args.len() != 6 {
return None;
}
let path = parsed.args.get(1)?.clone();
let mut name = None;
let mut saw_type_file = false;
let mut index = 2;
while index < parsed.args.len() {
match parsed.args[index].as_str() {
"-name" if name.is_none() && index + 1 < parsed.args.len() => {
name = Some(parsed.args[index + 1].clone());
index += 2;
}
"-type" if !saw_type_file && index + 1 < parsed.args.len() => {
if parsed.args[index + 1] != "f" {
return None;
}
saw_type_file = true;
index += 2;
}
_ => return None,
}
}
let name = name?;
let pattern = format!("**/{name}");
if path == "." {
Some(json!({ "pattern": pattern }))
} else {
let trimmed = path.trim_end_matches('/');
if trimmed.is_empty() {
None
} else {
Some(json!({ "path": trimmed, "pattern": pattern }))
}
}
}
fn cat_read_request(command: &str) -> Option<Value> {
let parsed = parse(command)?;
if parsed.appends_to.is_some() || parsed.heredoc.is_some() {
return None;
}
if parsed.args.len() != 2 || parsed.args.first()? != "cat" {
return None;
}
Some(json!({ "file": parsed.args[1] }))
}
fn append_request(command: &str) -> Option<Value> {
let parsed = parse(command)?;
let file = parsed.appends_to.clone()?;
let append_content = if parsed.args == ["cat"] {
parsed.heredoc?
} else if parsed.heredoc.is_none()
&& parsed.args.first().is_some_and(|arg| arg == "echo")
&& parsed.args.len() >= 2
&& !parsed.args[1].starts_with('-')
{
format!("{}\n", parsed.args[1..].join(" "))
} else {
return None;
};
Some(json!({
"op": "append",
"file": file,
"append_content": append_content,
"create_dirs": true,
}))
}
fn sed_request(command: &str) -> Option<Value> {
let parsed = parse(command)?;
if parsed.appends_to.is_some() || parsed.heredoc.is_some() {
return None;
}
if parsed.args.len() != 4 || parsed.args.first()? != "sed" || parsed.args[1] != "-n" {
return None;
}
let range = parsed.args[2].strip_suffix('p')?;
let (start, end) = range.split_once(',')?;
let start_line = start.parse::<u32>().ok()?;
let end_line = end.parse::<u32>().ok()?;
if start_line == 0 || end_line < start_line {
return None;
}
Some(json!({
"file": parsed.args[3],
"start_line": start_line,
"end_line": end_line,
}))
}
fn ls_request(command: &str, ctx: &AppContext) -> Option<Value> {
let parsed = parse(command)?;
if parsed.appends_to.is_some() || parsed.heredoc.is_some() || parsed.args.first()? != "ls" {
return None;
}
let mut path = None;
let mut include_hidden = false;
for arg in parsed.args.iter().skip(1) {
if let Some(flags) = arg.strip_prefix('-') {
if flags.is_empty() {
return None;
}
for flag in flags.chars() {
match flag {
'R' => {}
'a' => include_hidden = true,
'A' => return None,
_ => return None,
}
}
} else if path.is_none() {
path = Some(arg.clone());
} else {
return None;
}
}
let target = path.clone().unwrap_or_else(|| ".".to_string());
let root = grep_project_root(ctx);
let target_for_metadata = if Path::new(&target).is_absolute() {
Path::new(&target).to_path_buf()
} else {
root.join(&target)
};
if let Ok(metadata) = std::fs::metadata(&target_for_metadata) {
if !metadata.is_dir() || !path_is_safe(ctx, &target, true) {
return None;
}
} else {
return None;
}
Some(json!({ "file": target, "include_hidden": include_hidden }))
}
#[cfg(test)]
mod tests {
use std::fs;
use std::time::{Duration, SystemTime};
use serde_json::json;
use super::{find_request, should_suppress_grep_footer};
fn fixture() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
fs::create_dir(dir.path().join("src")).unwrap();
fs::write(dir.path().join("src/app.ts"), "foo\n").unwrap();
dir
}
#[test]
fn single_named_file_suppresses_grep_footer() {
let dir = fixture();
assert!(should_suppress_grep_footer(Some("src/app.ts"), dir.path()));
}
#[test]
fn directory_path_keeps_grep_footer() {
let dir = fixture();
filetime::set_file_mtime(
dir.path().join("src"),
filetime::FileTime::from_system_time(SystemTime::now() - Duration::from_secs(61)),
)
.unwrap();
assert!(!should_suppress_grep_footer(Some("src"), dir.path()));
}
#[test]
fn no_path_keeps_grep_footer() {
let dir = fixture();
assert!(!should_suppress_grep_footer(None, dir.path()));
}
#[test]
fn external_file_suppresses_grep_footer() {
let dir = fixture();
let external = tempfile::NamedTempFile::new().unwrap();
assert!(should_suppress_grep_footer(
external.path().to_str(),
dir.path()
));
}
#[test]
fn freshly_modified_file_suppresses_grep_footer() {
let dir = fixture();
let file = dir.path().join("src/app.ts");
fs::write(&file, "foo\nbar\n").unwrap();
assert!(should_suppress_grep_footer(file.to_str(), dir.path()));
}
#[test]
fn old_directory_inside_project_root_keeps_grep_footer() {
let dir = fixture();
let directory = dir.path().join("src");
filetime::set_file_mtime(
&directory,
filetime::FileTime::from_system_time(SystemTime::now() - Duration::from_secs(61)),
)
.unwrap();
assert!(!should_suppress_grep_footer(Some("src"), dir.path()));
}
#[test]
fn old_file_inside_project_root_suppresses_grep_footer() {
let dir = fixture();
let file = dir.path().join("src/app.ts");
filetime::set_file_mtime(
&file,
filetime::FileTime::from_system_time(SystemTime::now() - Duration::from_secs(61)),
)
.unwrap();
assert!(should_suppress_grep_footer(Some("src/app.ts"), dir.path()));
}
#[test]
fn find_absolute_path_uses_glob_path_arg() {
assert_eq!(
find_request(r#"find /tmp/foo -name "*.ts" -type f"#),
Some(json!({ "path": "/tmp/foo", "pattern": "**/*.ts" }))
);
}
#[test]
fn find_dot_keeps_project_root_relative_pattern() {
assert_eq!(
find_request(r#"find . -name "*.ts" -type f"#),
Some(json!({ "pattern": "**/*.ts" }))
);
}
#[test]
fn find_relative_path_uses_glob_path_arg() {
assert_eq!(
find_request(r#"find ./src -name "*.go""#),
Some(json!({ "path": "./src", "pattern": "**/*.go" }))
);
}
#[test]
fn find_trims_trailing_slash_from_path_arg() {
assert_eq!(
find_request(r#"find /tmp/foo/ -name "*.ts""#),
Some(json!({ "path": "/tmp/foo", "pattern": "**/*.ts" }))
);
}
#[test]
fn find_filesystem_root_is_not_rewritten() {
assert_eq!(find_request(r#"find / -name "*.rs""#), None);
assert_eq!(find_request(r#"find // -name "*.rs""#), None);
}
}