create directory

This commit is contained in:
jaberjaber23
2026-05-12 15:12:16 +03:00
parent efbefa1682
commit 8564872181
2 changed files with 198 additions and 0 deletions
+185
View File
@@ -205,6 +205,7 @@ pub async fn execute_tool(
"file_read" => tool_file_read(input, workspace_root).await,
"file_write" => tool_file_write(input, workspace_root).await,
"file_list" => tool_file_list(input, workspace_root).await,
"create_directory" => tool_create_directory(input, workspace_root).await,
"apply_patch" => tool_apply_patch(input, workspace_root).await,
// Web tools (upgraded: multi-provider search, SSRF-protected fetch)
@@ -595,6 +596,17 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
"required": ["path"]
}),
},
ToolDefinition {
name: "create_directory".to_string(),
description: "Create a directory (and any missing parent directories) at the given path. Paths are relative to the agent workspace. Idempotent: succeeds if the directory already exists.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "The directory path to create" }
},
"required": ["path"]
}),
},
ToolDefinition {
name: "apply_patch".to_string(),
description: "Apply a multi-hunk diff patch to add, update, move, or delete files. Use this for targeted edits instead of full file overwrites.".to_string(),
@@ -1358,6 +1370,80 @@ async fn tool_file_write(
))
}
/// Resolve a directory path for creation. Unlike `resolve_file_path`, this walks
/// up the path to find the nearest existing ancestor, canonicalizes that, and
/// re-appends the missing segments. This lets `create_directory` accept nested
/// paths like `a/b/c/d` even when none of `a`, `b`, `c` exist yet.
fn resolve_directory_path_for_create(
raw_path: &str,
workspace_root: Option<&Path>,
) -> Result<PathBuf, String> {
// Reject `..` components regardless of workspace.
let _ = validate_path(raw_path)?;
let Some(root) = workspace_root else {
return Ok(PathBuf::from(raw_path));
};
let path = Path::new(raw_path);
let candidate = if path.is_absolute() {
path.to_path_buf()
} else {
root.join(path)
};
let canon_root = root
.canonicalize()
.map_err(|e| format!("Failed to resolve workspace root: {e}"))?;
// Walk up to find the nearest existing ancestor, canonicalize it, then
// re-append the missing tail.
let mut existing: PathBuf = candidate.clone();
let mut tail: Vec<&std::ffi::OsStr> = Vec::new();
while !existing.exists() {
let Some(parent) = existing.parent() else {
return Err("Invalid path: no existing ancestor".to_string());
};
let Some(name) = existing.file_name() else {
return Err("Invalid path: no filename component".to_string());
};
tail.push(name);
existing = parent.to_path_buf();
}
let canon_existing = existing
.canonicalize()
.map_err(|e| format!("Failed to resolve ancestor directory: {e}"))?;
let mut resolved = canon_existing;
for segment in tail.into_iter().rev() {
resolved.push(segment);
}
if !resolved.starts_with(&canon_root) {
return Err(format!(
"Access denied: path '{raw_path}' resolves outside workspace"
));
}
Ok(resolved)
}
async fn tool_create_directory(
input: &serde_json::Value,
workspace_root: Option<&Path>,
) -> Result<String, String> {
let raw_path = input["path"].as_str().ok_or("Missing 'path' parameter")?;
if raw_path.is_empty() {
return Err("'path' parameter is empty".to_string());
}
let resolved = resolve_directory_path_for_create(raw_path, workspace_root)?;
tokio::fs::create_dir_all(&resolved)
.await
.map_err(|e| format!("Failed to create directory: {e}"))?;
Ok(format!("Created directory {}", resolved.display()))
}
async fn tool_file_list(
input: &serde_json::Value,
workspace_root: Option<&Path>,
@@ -3448,6 +3534,9 @@ mod tests {
let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
// Original 12
assert!(names.contains(&"file_read"));
assert!(names.contains(&"file_write"));
assert!(names.contains(&"file_list"));
assert!(names.contains(&"create_directory"));
assert!(names.contains(&"shell_exec"));
assert!(names.contains(&"agent_send"));
assert!(names.contains(&"agent_spawn"));
@@ -3619,6 +3708,102 @@ mod tests {
assert!(result.content.contains("traversal"));
}
#[tokio::test]
async fn test_create_directory_path_traversal_blocked() {
let result = execute_tool(
"test-id",
"create_directory",
&serde_json::json!({"path": "../../etc/evil"}),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None, // media_engine
None, // exec_policy
None, // tts_engine
None, // docker_config
None, // process_manager
)
.await;
assert!(result.is_error);
assert!(result.content.contains("traversal"));
}
#[tokio::test]
async fn test_create_directory_creates_nested() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
let result = tool_create_directory(
&serde_json::json!({"path": "a/b/c"}),
Some(root),
)
.await;
assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
let expected = root.join("a").join("b").join("c");
assert!(expected.is_dir(), "Expected directory to exist: {}", expected.display());
}
#[tokio::test]
async fn test_create_directory_idempotent() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
// First create
let r1 = tool_create_directory(
&serde_json::json!({"path": "data/logs"}),
Some(root),
)
.await;
assert!(r1.is_ok());
// Second create on existing dir should also succeed
let r2 = tool_create_directory(
&serde_json::json!({"path": "data/logs"}),
Some(root),
)
.await;
assert!(r2.is_ok(), "Expected idempotent success, got: {:?}", r2);
}
#[tokio::test]
async fn test_create_directory_missing_path_param() {
let result = tool_create_directory(&serde_json::json!({}), None).await;
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(msg.contains("Missing 'path'"), "got: {msg}");
}
#[tokio::test]
async fn test_create_directory_dispatch_via_execute_tool() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let result = execute_tool(
"test-id",
"create_directory",
&serde_json::json!({"path": "nested/folder"}),
None, // kernel
None, // allowed_tools
None, // caller_agent_id
None, // skill_registry
None, // mcp_connections
None, // web_ctx
None, // browser_ctx
None, // allowed_env_vars
Some(root.as_path()), // workspace_root
None, // media_engine
None, // exec_policy
None, // tts_engine
None, // docker_config
None, // process_manager
)
.await;
assert!(!result.is_error, "Expected success, got: {}", result.content);
assert!(root.join("nested").join("folder").is_dir());
}
#[tokio::test]
async fn test_file_list_path_traversal_blocked() {
let result = execute_tool(
+13
View File
@@ -15,6 +15,9 @@ pub fn map_tool_name(openclaw_name: &str) -> Option<&'static str> {
"Edit" | "edit" => Some("file_write"),
"Glob" | "glob" | "list_files" => Some("file_list"),
"Grep" | "grep" => Some("file_list"),
"Mkdir" | "mkdir" | "make_directory" | "makeDirectory" | "make_dir" | "makedir"
| "create_dir" | "createDir" | "createDirectory" | "new_directory" | "new_folder"
| "create_folder" | "createFolder" => Some("create_directory"),
"Bash" | "bash" | "exec" | "execute_command" => Some("shell_exec"),
"WebSearch" | "web_search" => Some("web_search"),
"WebFetch" | "fetch_url" | "web_fetch" => Some("web_fetch"),
@@ -56,6 +59,7 @@ pub fn is_known_openfang_tool(name: &str) -> bool {
"file_read"
| "file_write"
| "file_list"
| "create_directory"
| "shell_exec"
| "web_search"
| "web_fetch"
@@ -147,6 +151,14 @@ mod tests {
assert_eq!(map_tool_name("execute"), Some("shell_exec"));
assert_eq!(map_tool_name("shell"), Some("shell_exec"));
// create_directory aliases
assert_eq!(map_tool_name("Mkdir"), Some("create_directory"));
assert_eq!(map_tool_name("mkdir"), Some("create_directory"));
assert_eq!(map_tool_name("make_directory"), Some("create_directory"));
assert_eq!(map_tool_name("create_dir"), Some("create_directory"));
assert_eq!(map_tool_name("createDirectory"), Some("create_directory"));
assert_eq!(map_tool_name("create_folder"), Some("create_directory"));
// Unknown
assert_eq!(map_tool_name("unknown_tool"), None);
assert_eq!(map_tool_name(""), None);
@@ -180,6 +192,7 @@ mod tests {
"file_read",
"file_write",
"file_list",
"create_directory",
"shell_exec",
"web_search",
"web_fetch",