Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,23 @@ Accepted top-level keys:

Permission actions are lowercase strings: `allow`, `ask`, or `deny`. Each tool
rule can be a single action or an object mapping glob-like patterns to actions.
Supported permission tool keys are `bash`, `read`, `write`, `edit`, `grep`,
`find_files`, `list_dir`, `write_todo_list`, `apply_patch`, `lsp`, and
`question`. MCP-backed tools are checked under
`mcp_tool:{server_name}:{tool_name}`. Use `"*"` for the default action,
`external_directory` for absolute-path rules outside the working directory,
and `doom_loop` for repeated identical tool calls (default: `ask`). If
`bash` is omitted, dirge installs its built-in safe bash allow/deny rules.
Supported permission tool keys are:

- File / shell: `bash`, `read`, `write`, `edit`, `grep`, `find_files`,
`list_dir`, `apply_patch`, `write_todo_list`
- LSP / question: `lsp`, `question`
- Web: `webfetch`, `websearch`
- Subagent / state: `task`, `memory`, `skill`
- Semantic (tree-sitter): `list_symbols`, `get_symbol_body`,
`find_definition`, `find_callers`, `find_callees`
- MCP umbrella: `mcp_tool` — patterns match the full key
`mcp_tool:{server}:{tool}` so `{"mcp_tool:fs:*": "deny"}` blocks
every tool from a `fs` MCP server.

Use `"*"` for the default action, `external_directory` for
absolute-path rules outside the working directory, and `doom_loop`
for repeated identical tool calls (default: `ask`). If `bash` is
omitted, dirge installs its built-in safe bash allow/deny rules.

### Mode semantics

Expand Down
123 changes: 80 additions & 43 deletions src/agent/tools/apply_patch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,21 +69,52 @@ pub struct ApplyPatchArgs {
pub operations: Vec<PatchOp>,
}

fn apply_create(path: &str, content: &str) -> Result<String, String> {
/// Cap apply_patch read/write at 100 MiB. The tool isn't meant for
/// binary blobs or generated artifacts; an LLM pointing it at a
/// gigabyte file should fail fast rather than OOM the process.
const MAX_APPLY_PATCH_BYTES: u64 = 100 * 1024 * 1024;

async fn apply_create(path: &str, content: &str) -> Result<String, String> {
let p = Path::new(path);
if p.exists() {
if tokio::fs::try_exists(p).await.unwrap_or(false) {
return Err(format!("file already exists: {}", path));
}
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent)
if let Some(parent) = p.parent()
&& !parent.as_os_str().is_empty()
{
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| format!("failed to create parent dir: {}", e))?;
}
std::fs::write(p, content).map_err(|e| format!("write failed: {}", e))?;
if content.len() as u64 > MAX_APPLY_PATCH_BYTES {
return Err(format!(
"create content too large: {} bytes (cap {} bytes)",
content.len(),
MAX_APPLY_PATCH_BYTES,
));
}
tokio::fs::write(p, content)
.await
.map_err(|e| format!("write failed: {}", e))?;
Ok(format!("created {}", path))
}

fn apply_update(path: &str, old_text: &str, new_text: &str) -> Result<String, String> {
let original = std::fs::read_to_string(path).map_err(|e| format!("read failed: {}", e))?;
async fn apply_update(path: &str, old_text: &str, new_text: &str) -> Result<String, String> {
// Pre-check size before reading the file into memory. The
// metadata call is cheap (single stat); rejecting here avoids
// a multi-GB allocation in `read_to_string`.
if let Ok(meta) = tokio::fs::metadata(path).await
&& meta.len() > MAX_APPLY_PATCH_BYTES
{
return Err(format!(
"file too large for apply_patch: {} bytes (cap {} bytes); use bash + sed/awk for huge files",
meta.len(),
MAX_APPLY_PATCH_BYTES,
));
}
let original = tokio::fs::read_to_string(path)
.await
.map_err(|e| format!("read failed: {}", e))?;

// CRLF normalization to match `edit.rs`. The LLM almost always
// generates `\n` in `old_text` even when the file is CRLF on
Expand Down Expand Up @@ -124,17 +155,23 @@ fn apply_update(path: &str, old_text: &str, new_text: &str) -> Result<String, St
} else {
updated_normalized
};
std::fs::write(path, &to_write).map_err(|e| format!("write failed: {}", e))?;
tokio::fs::write(path, &to_write)
.await
.map_err(|e| format!("write failed: {}", e))?;
Ok(format!("updated {}", path))
}

fn apply_delete(path: &str) -> Result<String, String> {
std::fs::remove_file(path).map_err(|e| format!("delete failed: {}", e))?;
async fn apply_delete(path: &str) -> Result<String, String> {
tokio::fs::remove_file(path)
.await
.map_err(|e| format!("delete failed: {}", e))?;
Ok(format!("deleted {}", path))
}

fn apply_rename(path: &str, new_path: &str) -> Result<String, String> {
std::fs::rename(path, new_path).map_err(|e| format!("rename failed: {}", e))?;
async fn apply_rename(path: &str, new_path: &str) -> Result<String, String> {
tokio::fs::rename(path, new_path)
.await
.map_err(|e| format!("rename failed: {}", e))?;
Ok(format!("renamed {} -> {}", path, new_path))
}

Expand Down Expand Up @@ -299,14 +336,14 @@ impl Tool for ApplyPatchTool {
}

let result = match op {
PatchOp::Create { path, content } => apply_create(path, content),
PatchOp::Create { path, content } => apply_create(path, content).await,
PatchOp::Update {
path,
old_text,
new_text,
} => apply_update(path, old_text, new_text),
PatchOp::Delete { path } => apply_delete(path),
PatchOp::Rename { path, new_path } => apply_rename(path, new_path),
} => apply_update(path, old_text, new_text).await,
PatchOp::Delete { path } => apply_delete(path).await,
PatchOp::Rename { path, new_path } => apply_rename(path, new_path).await,
};

match result {
Expand Down Expand Up @@ -372,59 +409,59 @@ mod tests {
}
}

#[test]
fn test_create_and_read() {
#[tokio::test]
async fn test_create_and_read() {
let tf = TestFile::new("create-test.txt");
let result = apply_create(&tf.path, "hello world");
let result = apply_create(&tf.path, "hello world").await;
assert!(result.is_ok());
let content = std::fs::read_to_string(&tf.path).unwrap();
assert_eq!(content, "hello world");
}

#[test]
fn test_create_existing_file_fails() {
#[tokio::test]
async fn test_create_existing_file_fails() {
let tf = TestFile::new("create-exists.txt");
std::fs::write(&tf.path, "existing").unwrap();
let result = apply_create(&tf.path, "new");
let result = apply_create(&tf.path, "new").await;
assert!(result.is_err());
}

#[test]
fn test_update_text() {
#[tokio::test]
async fn test_update_text() {
let tf = TestFile::new("update-test.txt");
std::fs::write(&tf.path, "before after").unwrap();
let result = apply_update(&tf.path, "before", "replaced");
let result = apply_update(&tf.path, "before", "replaced").await;
assert!(result.is_ok());
let content = std::fs::read_to_string(&tf.path).unwrap();
assert_eq!(content, "replaced after");
}

#[test]
fn test_update_text_not_found() {
#[tokio::test]
async fn test_update_text_not_found() {
let tf = TestFile::new("update-notfound.txt");
std::fs::write(&tf.path, "some content").unwrap();
let result = apply_update(&tf.path, "nonexistent", "replacement");
let result = apply_update(&tf.path, "nonexistent", "replacement").await;
assert!(result.is_err());
}

#[test]
fn test_delete_file() {
#[tokio::test]
async fn test_delete_file() {
let tf = TestFile::new("delete-test.txt");
std::fs::write(&tf.path, "to delete").unwrap();
assert!(Path::new(&tf.path).exists());
let result = apply_delete(&tf.path);
let result = apply_delete(&tf.path).await;
assert!(result.is_ok());
assert!(!Path::new(&tf.path).exists());
}

#[test]
fn test_rename_file() {
#[tokio::test]
async fn test_rename_file() {
let src = TestFile::new("rename-src.txt");
let dst = "/tmp/dirge-test-rename-dst.txt";
let _ = std::fs::remove_file(dst);
std::fs::write(&src.path, "rename me").unwrap();

let result = apply_rename(&src.path, dst);
let result = apply_rename(&src.path, dst).await;
assert!(result.is_ok());
assert!(!Path::new(&src.path).exists());
assert!(Path::new(dst).exists());
Expand All @@ -450,11 +487,11 @@ mod tests {
// ambiguous matches rather than silently replacing the first one. Without
// this guard the agent could clobber wrong code in a file with repeated
// boilerplate (use statements, similar function bodies, etc.).
#[test]
fn regression_update_rejects_multiple_matches() {
#[tokio::test]
async fn regression_update_rejects_multiple_matches() {
let tf = TestFile::new("update-ambiguous.txt");
std::fs::write(&tf.path, "foo bar foo baz foo").unwrap();
let result = apply_update(&tf.path, "foo", "qux");
let result = apply_update(&tf.path, "foo", "qux").await;
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(msg.contains("3 locations"), "got: {msg}");
Expand Down Expand Up @@ -568,25 +605,25 @@ mod tests {
}

// create_dir_all is called on the parent — confirms nested-path creates work.
#[test]
fn create_creates_parent_dirs() {
#[tokio::test]
async fn create_creates_parent_dirs() {
let dir = std::env::temp_dir().join(format!("dirge-test-nested-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let nested = dir.join("a/b/c/file.txt");
let path_str = nested.to_str().unwrap();

let result = apply_create(path_str, "deep content");
let result = apply_create(path_str, "deep content").await;
assert!(result.is_ok());
assert_eq!(std::fs::read_to_string(&nested).unwrap(), "deep content");

let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn delete_missing_file_returns_err() {
#[tokio::test]
async fn delete_missing_file_returns_err() {
let path = format!("/tmp/dirge-test-delete-ghost-{}.txt", std::process::id());
let _ = std::fs::remove_file(&path);
let result = apply_delete(&path);
let result = apply_delete(&path).await;
assert!(result.is_err());
}

Expand Down
14 changes: 14 additions & 0 deletions src/agent/tools/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,20 @@ impl Tool for EditTool {
}
}

// Pre-check size before reading. The edit tool isn't meant
// for huge generated artifacts; cap at 100 MiB so an LLM
// pointing it at a gigabyte log file fails fast rather
// than OOM-ing the process. Matches the apply_patch cap.
const MAX_EDIT_BYTES: u64 = 100 * 1024 * 1024;
if let Ok(meta) = tokio::fs::metadata(&args.path).await
&& meta.len() > MAX_EDIT_BYTES
{
return Err(ToolError::Msg(format!(
"file too large for edit: {} bytes (cap {} bytes); use bash with sed/awk for huge files",
meta.len(),
MAX_EDIT_BYTES,
)));
}
let bytes = tokio::fs::read(&args.path).await?;
let has_crlf = bytes.windows(2).any(|w| w == b"\r\n");
let content = String::from_utf8_lossy(&bytes).replace("\r\n", "\n");
Expand Down
8 changes: 4 additions & 4 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,12 @@ pub struct Config {
#[cfg(feature = "mcp")]
pub mcp_servers: Option<HashMap<String, McpServerConfig>>,

/// ACP server config map when compiled with the `acp` feature.
/// Used by the editor-integration server; dirge's ACP transport
/// is stdio-only — the TCP / Unix-socket forms live here for
/// future expansion but are not honored today.
#[cfg(feature = "acp")]
pub acp_servers: Option<HashMap<String, AcpServerConfig>>,
#[cfg(feature = "acp")]
pub acp_host: Option<String>,
#[cfg(feature = "acp")]
pub acp_port: Option<u16>,
}

impl Config {
Expand Down
16 changes: 15 additions & 1 deletion src/extras/mcp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,16 @@ impl McpClientManager {
handles.push(handle);
}
Err(e) => {
// ALSO emit to stderr so users running without
// RUST_LOG / --verbose see that an MCP server
// failed to register. Without this, configured
// tools just silently never appear and the user
// has no idea why.
tracing::warn!("Failed to connect to MCP server '{}': {e}", name);
eprintln!(
"warning: MCP server '{}' failed to connect: {}; its tools won't be available this session",
name, e,
);
}
}
}
Expand Down Expand Up @@ -54,7 +63,12 @@ impl McpClientManager {
Err(e) => {
tracing::warn!(
"Failed to list tools from MCP server '{}': {e}",
server_name
server_name,
);
eprintln!(
"warning: MCP server '{}' connected but list_tools failed: {}; \
its tools won't be available this session",
server_name, e,
);
}
}
Expand Down
31 changes: 30 additions & 1 deletion src/permission/checker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,22 @@ pub struct PermissionChecker {
pub(crate) fn is_path_tool_name(tool: &str) -> bool {
matches!(
tool,
"read" | "write" | "edit" | "list_dir" | "apply_patch" | "lsp"
"read"
| "write"
| "edit"
| "list_dir"
| "apply_patch"
| "lsp"
// grep / find_files / glob now also receive path-side
// checks (the search-root path), so their rules use
// path-glob semantics.
| "grep"
| "find_files"
| "glob"
// Semantic tools whose primary arg is a file path.
| "list_symbols"
| "get_symbol_body"
| "find_callees"
)
}

Expand Down Expand Up @@ -74,6 +89,20 @@ impl PermissionChecker {
("apply_patch", &config.apply_patch),
("lsp", &config.lsp),
("question", &config.question),
// Newly-configurable tools (previously the perm checker
// had no rules for them, so they always fell through to
// the `*` default and couldn't be individually gated).
("webfetch", &config.webfetch),
("websearch", &config.websearch),
("task", &config.task),
("memory", &config.memory),
("skill", &config.skill),
("list_symbols", &config.list_symbols),
("get_symbol_body", &config.get_symbol_body),
("find_definition", &config.find_definition),
("find_callers", &config.find_callers),
("find_callees", &config.find_callees),
("mcp_tool", &config.mcp_tool),
] {
let Some(tp) = tool_perm else { continue };
let mut entries = Vec::new();
Expand Down
Loading
Loading