pub(crate) mod apply_patch;
pub(crate) mod background;
pub(crate) mod bash;
pub(crate) mod bg_shell;
pub(crate) mod cache;
#[cfg(feature = "dap")]
pub(crate) mod debug;
pub(crate) mod edit;
pub(crate) mod edit_lines;
#[cfg(feature = "semantic")]
mod edit_minified;
mod find_files;
mod glob;
mod grep;
pub(crate) mod line_hash;
mod list_dir;
#[cfg(feature = "lsp")]
mod lsp;
mod memory;
pub(crate) mod modified;
pub(crate) mod output_relay;
pub(crate) mod plan;
pub(crate) mod question;
mod read;
#[cfg(feature = "semantic")]
mod read_minified;
mod repo_overview;
#[cfg(feature = "semantic")]
pub mod semantic;
mod session_search;
mod skill;
pub(crate) mod snapshots;
mod spec;
pub mod task;
mod task_status;
pub(crate) mod todo;
pub mod tool_search;
mod webfetch;
mod websearch;
pub(crate) mod write;
#[cfg(feature = "experimental-graph-search")]
mod graph;
pub use apply_patch::ApplyPatchTool;
pub use bash::BashTool;
pub use bg_shell::{BashOutputTool, KillShellTool};
pub use cache::ToolCache;
#[cfg(feature = "dap")]
pub use debug::DebugTool;
pub use edit::EditTool;
pub use edit_lines::EditLinesTool;
#[cfg(feature = "semantic")]
pub use edit_minified::EditMinifiedTool;
pub use find_files::FindFilesTool;
pub use glob::GlobTool;
#[cfg(feature = "experimental-graph-search")]
pub use graph::GraphTool;
pub use grep::GrepTool;
pub use list_dir::ListDirTool;
#[cfg(feature = "lsp")]
pub use lsp::LspTool;
pub use memory::MemoryTool;
pub use plan::{PlanEnterTool, PlanExitTool};
pub use question::QuestionTool;
pub use read::ReadTool;
#[cfg(feature = "semantic")]
pub use read_minified::ReadMinifiedTool;
pub use repo_overview::RepoOverviewTool;
pub use session_search::SessionSearchTool;
pub use skill::SkillTool;
pub use spec::SpecTool;
pub use task::TaskTool;
pub use task_status::TaskStatusTool;
pub use todo::WriteTodoList;
#[allow(unused_imports)]
pub use tool_search::{ALWAYS_ON_TOOLS, TOOL_SEARCH_NAME, ToolMeta, ToolSearchTool};
pub use webfetch::WebFetchTool;
pub use websearch::WebSearchTool;
pub use write::WriteTool;
#[allow(unused_imports)]
use crate::sync_util::LockExt;
use std::io;
use serde::Deserialize;
use crate::permission::ask::{AskRequest, AskSender, UserDecision};
use crate::permission::checker::PermCheck;
pub const MAX_GREP_RESULTS: usize = 200;
pub const MAX_FIND_RESULTS: usize = 200;
pub const BUILTIN_TOOL_NAMES: &[&str] = &[
"read",
"read_minified",
"write",
"edit",
"edit_lines",
"edit_minified",
"bash",
"grep",
"find_files",
"glob",
"list_dir",
"write_todo_list",
"apply_patch",
"memory",
"skill",
"task",
"task_status",
"bash_output",
"kill_shell",
"tool_search",
"question",
"webfetch",
"websearch",
"lsp",
"debug",
"repo_overview",
"spec",
"session_search",
"search_graph",
"list_symbols",
"get_symbol_body",
"find_definition",
"find_callers",
"find_callees",
"plan_enter",
"plan_exit",
"mcp_tool",
"plugin_tool",
];
#[derive(Debug, thiserror::Error)]
pub enum ToolError {
#[error("{0}")]
Msg(String),
}
pub const DENIAL_PREFIX: &str = "Permission denied";
pub const AUTO_DENIAL_PREFIX: &str = "Auto-approval denied by approval_provider";
pub fn is_permission_denial(text: &str) -> bool {
let t = text.trim_start();
t.starts_with(DENIAL_PREFIX) || t.starts_with(AUTO_DENIAL_PREFIX)
}
impl From<io::Error> for ToolError {
fn from(e: io::Error) -> Self {
ToolError::Msg(e.to_string())
}
}
impl From<serde_json::Error> for ToolError {
fn from(e: serde_json::Error) -> Self {
ToolError::Msg(e.to_string())
}
}
pub fn is_skip_dir(name: &str) -> bool {
matches!(name, "node_modules" | "target")
}
pub fn head_cap(text: String, max_bytes: usize, what: &str) -> String {
if text.len() <= max_bytes {
return text;
}
let mut cut = max_bytes;
while cut > 0 && !text.is_char_boundary(cut) {
cut -= 1;
}
let total = text.len();
let dropped = total - cut;
let mut out = text;
out.truncate(cut);
out.push_str(&format!(
"\n…[{what} truncated: dropped {dropped} of {total} bytes; narrow the command (head/grep) to keep context lean]"
));
out
}
pub fn required_nonblank<'a>(
value: Option<&'a str>,
field: &str,
action: &str,
) -> Result<&'a str, ToolError> {
match value {
Some(s) if !s.trim().is_empty() => Ok(s),
_ => Err(ToolError::Msg(format!(
"`{field}` is required for action '{action}'"
))),
}
}
pub fn require_absolute_path(path: &str, subject: &str) -> Result<(), String> {
if std::path::Path::new(path).is_absolute() {
Ok(())
} else {
Err(format!(
"{subject} must be an absolute path like '/home/user/project/file.txt', \
not a relative path or bare filename — got {path:?}"
))
}
}
pub(crate) fn syntax_gate<'a>(
path: &std::path::Path,
content: &'a str,
) -> Result<(std::borrow::Cow<'a, str>, Option<String>), String> {
#[cfg(feature = "semantic")]
{
use crate::semantic::syntax_validator::{SyntaxOutcome, validate_or_repair};
match validate_or_repair(path, content) {
SyntaxOutcome::Clean => Ok((std::borrow::Cow::Borrowed(content), None)),
SyntaxOutcome::Repaired { content, note } => {
Ok((std::borrow::Cow::Owned(content), Some(note)))
}
SyntaxOutcome::Rejected { message } => Err(message),
}
}
#[cfg(not(feature = "semantic"))]
{
let _ = path;
Ok((std::borrow::Cow::Borrowed(content), None))
}
}
pub(crate) fn append_repair_note(msg: &mut String, note: Option<String>) {
if let Some(note) = note {
msg.push_str(&format!("\n[auto-repair] {note}"));
}
}
#[derive(Deserialize)]
pub struct ReadArgs {
pub path: String,
pub offset: Option<usize>,
pub limit: Option<usize>,
pub line_hashes: Option<bool>,
}
#[derive(Deserialize)]
pub struct WriteArgs {
pub path: String,
pub content: String,
}
#[derive(Deserialize)]
pub struct EditArgs {
pub path: String,
pub old_text: String,
pub new_text: String,
pub replace_all: Option<bool>,
}
#[derive(Deserialize)]
pub struct EditLinesArgs {
pub path: String,
pub start_line: usize,
pub end_line: usize,
pub expected_hashes: Vec<String>,
pub new_text: String,
}
#[derive(Deserialize)]
pub struct BashArgs {
pub command: String,
pub timeout: Option<u64>,
#[serde(default)]
pub background: Option<bool>,
}
#[derive(Deserialize)]
pub struct GrepArgs {
pub pattern: String,
pub path: Option<String>,
pub include: Option<String>,
pub context_lines: Option<usize>,
#[serde(default)]
pub include_hidden: bool,
}
#[derive(Deserialize)]
pub struct FindFilesArgs {
pub pattern: String,
pub path: Option<String>,
#[serde(default)]
pub include_hidden: bool,
}
#[derive(Deserialize)]
pub struct ListDirArgs {
pub path: Option<String>,
#[serde(default)]
pub include_hidden: bool,
}
async fn handle_ask_inner(
ask_tx: &AskSender,
permission: &PermCheck,
tool: &str,
input: &str,
reason: Option<&str>,
) -> Result<(), ToolError> {
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
ask_tx
.send(AskRequest {
tool: tool.to_string(),
input: input.to_string(),
reason: reason.map(str::to_string),
reply: reply_tx,
})
.await
.map_err(|_| ToolError::Msg("Permission system unavailable".to_string()))?;
match reply_rx.await {
Ok(UserDecision::AllowOnce) => Ok(()),
Ok(UserDecision::AllowAlways(pattern)) => {
permission
.lock_ignore_poison()
.add_session_allowlist(tool.to_string(), &pattern);
Ok(())
}
_ => Err(ToolError::Msg(format!("{DENIAL_PREFIX} by user"))),
}
}
enum AutoVerdict {
Allow,
Deny(String),
Abstain,
}
async fn try_auto_approve(
perm: &PermCheck,
tool: &str,
command: &str,
resources: Vec<String>,
) -> AutoVerdict {
use crate::permission::approval::{ApprovalDecision, ApprovalRequest};
let (f, working_dir) = {
let g = perm.lock_ignore_poison();
match g.approval_fn() {
Some(f) => (f, g.working_dir().to_string()),
None => return AutoVerdict::Abstain,
}
};
let req = ApprovalRequest {
tool: tool.to_string(),
command: command.to_string(),
working_dir,
resources,
};
match f(req).await {
Ok(ApprovalDecision::Allow) => {
tracing::info!(target: "dirge::permission", tool, command, "auto-approval: ALLOW");
AutoVerdict::Allow
}
Ok(ApprovalDecision::Deny(reason)) => {
tracing::info!(target: "dirge::permission", tool, command, %reason, "auto-approval: DENY (escalating to human)");
AutoVerdict::Deny(reason)
}
Err(e) => {
tracing::warn!(target: "dirge::permission", error = %e, "approval_provider call failed; falling back to human prompt");
AutoVerdict::Abstain
}
}
}
async fn resolve_auto_verdict(
verdict: AutoVerdict,
ask_tx: &Option<AskSender>,
perm: &PermCheck,
tool: &str,
input: &str,
) -> Result<bool, ToolError> {
let (reason, no_human_msg) = match verdict {
AutoVerdict::Allow => return Ok(true),
AutoVerdict::Deny(reason) => {
let msg = format!("{AUTO_DENIAL_PREFIX}: {reason}");
(Some(reason), msg)
}
AutoVerdict::Abstain => (None, format!("{DENIAL_PREFIX} (non-interactive mode)")),
};
let Some(tx) = ask_tx else {
return Err(ToolError::Msg(no_human_msg));
};
handle_ask_inner(tx, perm, tool, input, reason.as_deref()).await?;
Ok(false)
}
pub enum Scope<'a> {
Raw(&'a str),
Path(&'a str),
PathResolve(&'a str),
}
pub async fn enforce(
permission: &Option<PermCheck>,
ask_tx: &Option<AskSender>,
tool: &str,
scope: Scope<'_>,
) -> Result<String, ToolError> {
let raw_scope: &str = match &scope {
Scope::Raw(s) | Scope::Path(s) | Scope::PathResolve(s) => s,
};
let Some(perm) = permission else {
return Ok(raw_scope.to_string());
};
let is_path = matches!(scope, Scope::Path(_) | Scope::PathResolve(_));
let (effect, reason, resolved) = {
let mut guard = perm.lock_ignore_poison();
let decision = guard.authorize_scope(tool, raw_scope, is_path);
let resolved = match scope {
Scope::PathResolve(_) => decision
.resolved_paths
.first()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| raw_scope.to_string()),
_ => raw_scope.to_string(),
};
(decision.effect, decision.reason(), resolved)
};
use crate::permission::engine::types::Effect;
match effect {
Effect::Allow => Ok(resolved),
Effect::Deny => Err(ToolError::Msg(format!("{DENIAL_PREFIX}: {reason}"))),
Effect::Ask => {
let verdict = try_auto_approve(perm, tool, raw_scope, Vec::new()).await;
resolve_auto_verdict(verdict, ask_tx, perm, tool, raw_scope).await?;
perm.lock_ignore_poison()
.note_allowed_scope(tool, raw_scope, is_path);
Ok(resolved)
}
}
}
pub async fn enforce_request(
permission: &Option<PermCheck>,
ask_tx: &Option<AskSender>,
req: crate::permission::engine::types::AccessRequest,
) -> Result<(), ToolError> {
use crate::permission::engine::types::Effect;
let Some(perm) = permission else {
return Ok(()); };
let (effect, reason) = {
let mut guard = perm.lock_ignore_poison();
let decision = guard.authorize_request(&req);
(decision.effect, decision.reason())
};
match effect {
Effect::Allow => Ok(()),
Effect::Deny => Err(ToolError::Msg(format!("{DENIAL_PREFIX}: {reason}"))),
Effect::Ask => {
let resources = crate::permission::approval::summarize_claims(&req.claims);
let verdict = try_auto_approve(perm, &req.tool, &req.display_input, resources).await;
resolve_auto_verdict(verdict, ask_tx, perm, &req.tool, &req.display_input).await?;
perm.lock_ignore_poison().note_allowed_request(&req);
Ok(())
}
}
}
pub async fn check_perm(
permission: &Option<PermCheck>,
ask_tx: &Option<AskSender>,
tool: &str,
input_key: &str,
) -> Result<(), ToolError> {
enforce(permission, ask_tx, tool, Scope::Raw(input_key))
.await
.map(|_| ())
}
pub async fn check_perm_path(
permission: &Option<PermCheck>,
ask_tx: &Option<AskSender>,
tool: &str,
path: &str,
) -> Result<(), ToolError> {
enforce(permission, ask_tx, tool, Scope::Path(path))
.await
.map(|_| ())
}
pub async fn check_perm_path_resolve(
permission: &Option<PermCheck>,
ask_tx: &Option<AskSender>,
tool: &str,
path: &str,
) -> Result<String, ToolError> {
enforce(permission, ask_tx, tool, Scope::PathResolve(path)).await
}
pub async fn require_and_resolve(
permission: &Option<PermCheck>,
ask_tx: &Option<AskSender>,
tool: &str,
path: &str,
subject: &str,
) -> Result<String, ToolError> {
require_absolute_path(path, subject).map_err(ToolError::Msg)?;
check_perm_path_resolve(permission, ask_tx, tool, path).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::permission::{
Action, OpSpec, PermissionConfig, RuleConfig, SecurityMode, checker::PermissionChecker,
};
use std::sync::{Arc, Mutex};
#[test]
fn is_permission_denial_recognizes_every_enforce_denial_form() {
assert!(is_permission_denial(
"Permission denied: writes outside project"
));
assert!(is_permission_denial("Permission denied by user"));
assert!(is_permission_denial(
"Permission denied (non-interactive mode)"
));
assert!(is_permission_denial(
"Auto-approval denied by approval_provider: file is outside the project directory"
));
assert!(is_permission_denial(" Permission denied: x"));
assert!(!is_permission_denial("old_string not found in file"));
assert!(!is_permission_denial("Command timed out after 120s"));
assert!(!is_permission_denial(
"error: the user lacks permission denied elsewhere in sentence"
));
}
fn checker_with_denying_evaluator(reason: &'static str) -> PermCheck {
use crate::permission::approval::ApprovalDecision;
let config = PermissionConfig {
rules: vec![rule(OpSpec::Edit, "**", Action::Ask)],
..Default::default()
};
let mut checker = PermissionChecker::new(
&config,
SecurityMode::Standard,
Some(std::path::PathBuf::from("/tmp")),
);
checker.set_approval_fn(Arc::new(move |_req| {
Box::pin(async move { Ok(ApprovalDecision::Deny(reason.to_string())) })
}));
Arc::new(Mutex::new(checker))
}
#[tokio::test]
async fn approval_provider_deny_escalates_to_human_who_can_allow() {
use crate::permission::ask::{AskRequest, UserDecision};
let perm = checker_with_denying_evaluator("writes outside project");
let (tx, mut rx) = tokio::sync::mpsc::channel::<AskRequest>(1);
let human = tokio::spawn(async move {
let req = rx.recv().await.expect("a prompt must reach the human");
assert_eq!(
req.reason.as_deref(),
Some("writes outside project"),
"escalated deny prompt must carry the evaluator's reason"
);
let _ = req.reply.send(UserDecision::AllowOnce);
});
let result = enforce(
&Some(perm),
&Some(tx),
"write",
Scope::PathResolve("/tmp/x.rs"),
)
.await;
assert!(
result.is_ok(),
"human override of an evaluator deny should allow: {result:?}"
);
human.await.unwrap();
}
#[tokio::test]
async fn approval_provider_deny_is_terminal_without_a_human() {
let perm = checker_with_denying_evaluator("writes outside project");
let result = enforce(&Some(perm), &None, "write", Scope::PathResolve("/tmp/x.rs")).await;
let err = result.unwrap_err().to_string();
assert!(
err.contains(AUTO_DENIAL_PREFIX) && err.contains("writes outside project"),
"non-interactive deny keeps the evaluator reason: {err}"
);
assert!(
is_permission_denial(&err),
"still a recognized denial: {err}"
);
}
fn rule(op: OpSpec, pattern: &str, effect: Action) -> RuleConfig {
RuleConfig {
op,
pattern: pattern.to_string(),
effect,
tool: None,
}
}
#[test]
fn required_nonblank_extracts_or_errors() {
assert_eq!(
required_nonblank(Some("hello"), "content", "add").unwrap(),
"hello"
);
for bad in [None, Some(""), Some(" \t")] {
let msg = required_nonblank(bad, "content", "add")
.unwrap_err()
.to_string();
assert!(msg.contains("content"), "names the field: {msg}");
assert!(msg.contains("add"), "names the action: {msg}");
}
}
#[test]
fn head_cap_passes_short_and_marks_truncation() {
assert_eq!(head_cap("short".to_string(), 100, "x"), "short");
let capped = head_cap("a".repeat(50), 10, "bash output");
assert!(capped.starts_with(&"a".repeat(10)), "kept head: {capped}");
assert!(capped.contains("truncated"), "marked: {capped}");
assert!(
capped.contains("dropped 40 of 50 bytes"),
"counts: {capped}"
);
let capped = head_cap("é".repeat(10), 5, "x");
assert!(capped.starts_with("éé"), "boundary-safe head: {capped}");
assert!(capped.contains("truncated"));
}
#[test]
fn require_absolute_path_accepts_absolute_rejects_relative() {
assert!(require_absolute_path("/home/user/x.rs", "read path").is_ok());
for bad in ["x.rs", "./x.rs", "../x.rs", "src/x.rs", "1"] {
let err = require_absolute_path(bad, "read path")
.expect_err("relative path must be rejected");
assert!(err.contains("absolute path"), "message: {err}");
assert!(err.contains(bad), "message names the offending path: {err}");
}
}
#[tokio::test]
async fn enforce_write_aliases_to_edit_deny() {
let config = PermissionConfig {
rules: vec![rule(OpSpec::Edit, "**", Action::Deny)],
..Default::default()
};
let checker = PermissionChecker::new(
&config,
SecurityMode::Standard,
Some(std::path::PathBuf::from("/tmp")),
);
let perm: PermCheck = Arc::new(Mutex::new(checker));
let result = enforce(
&Some(perm.clone()),
&None,
"write",
Scope::PathResolve("/tmp/x.rs"),
)
.await;
assert!(
matches!(result, Err(_)),
"edit deny should propagate to write; got {result:?}",
);
let result = enforce(
&Some(perm),
&None,
"apply_patch",
Scope::PathResolve("/tmp/x.rs"),
)
.await;
assert!(
matches!(result, Err(_)),
"edit deny should propagate to apply_patch; got {result:?}",
);
}
#[tokio::test]
async fn enforce_write_alias_most_restrictive_wins() {
let config = PermissionConfig {
rules: vec![
rule(OpSpec::Edit, "**", Action::Allow),
rule(OpSpec::Edit, "/etc/**", Action::Deny),
],
..Default::default()
};
let checker = PermissionChecker::new(&config, SecurityMode::Standard, None);
let perm: PermCheck = Arc::new(Mutex::new(checker));
let result = enforce(
&Some(perm.clone()),
&None,
"write",
Scope::PathResolve("/etc/passwd"),
)
.await;
assert!(matches!(result, Err(_)));
let result = enforce(&Some(perm), &None, "write", Scope::PathResolve("/tmp/x.rs")).await;
assert!(
result.is_ok(),
"/tmp/x.rs: `write **: allow` governs (edit `/etc/**` deny doesn't match) → Allow; got {result:?}",
);
}
#[tokio::test]
async fn enforce_read_does_not_alias_to_edit() {
let config = PermissionConfig {
rules: vec![rule(OpSpec::Edit, "**", Action::Deny)],
..Default::default()
};
let checker = PermissionChecker::new(&config, SecurityMode::Standard, None);
let perm: PermCheck = Arc::new(Mutex::new(checker));
let result = enforce(
&Some(perm),
&None,
"read",
Scope::PathResolve("anywhere.rs"),
)
.await;
assert!(
matches!(result, Ok(_)),
"read isn't aliased to edit; should pass via builtin-allow; got {result:?}",
);
}
}