diff --git a/README.md b/README.md
index eb9cba7..0c50e49 100644
--- a/README.md
+++ b/README.md
@@ -58,12 +58,13 @@ MCP servers, timeouts, permissions, and agent behavior. LLM settings come from e
"plan_required": true,
"execute_enabled": false
},
- "permissions": [
- { "operations": ["read"], "paths": ["./*"], "mode": "allow" }
- ]
+ "writable_paths": ["./src/*", "/tmp/out/*"],
+ "permissions": []
}
```
+By default all paths are **read-only**. Only paths listed in `writable_paths` can be written to (by `write_file`, `edit_file`, `execute`). Patterns use glob syntax (`**`, `*`, `?`). The `permissions` array is kept for advanced allow/deny rules and is checked as a fallback when `writable_paths` does not match.
+
### Timeout Settings
Timeouts can be set in `agent-runner.json` and overridden by CLI flags:
@@ -137,9 +138,10 @@ API keys can be provided via:
"keep_tokens": 20000,
"trim_tokens": 4000
},
+ "writable_paths": ["./src/*"],
"permissions": [
{
- "operations": ["read"],
+ "operations": ["write"],
"paths": ["./*"],
"mode": "allow"
}
@@ -158,10 +160,11 @@ agent-runner --agent-dir
--prompt [OPTIONS]
|--------|---------|-------------|
| `--agent-dir` | (required) | Path to agent folder |
| `--prompt` | (required) | Task prompt or path to a text file |
-| `--plan-only` | `false` | Generate plan and exit without executing |
+| `--plan-only` | `false` | Generate plan (`plan.json`) and exit without executing |
| `--max-iterations` | `50` | Maximum agent loop iterations |
| `--output-dir` | `./agent-output` | Output directory for reports and traces |
| `--working-dir` | `.` | Working directory for filesystem/execute tools |
+| `--writable-paths` | (from config) | Comma-separated glob patterns of writable paths (e.g. `./src/*,/tmp/*`) |
| `--tool-timeout` | `120` | Timeout in seconds for each tool call |
| `--run-limit` | `3600` | Maximum total run time in seconds |
| `--verbose` | `false` | Print iteration details to stderr |
@@ -186,28 +189,37 @@ The agent has these tools available by default:
| `read_file` | Read file contents with line-based pagination |
| `write_file` | Write content to a file (creates parent dirs) |
| `edit_file` | Find-and-replace strings in a file |
-| `glob` | Find files matching a glob pattern |
-| `grep` | Search file contents with regex |
+| `glob` | Find files matching a glob pattern (respects `.gitignore`) |
+| `grep` | Search file contents with regex (respects `.gitignore`) |
| `execute` | Run a shell command (when enabled) |
+| `read_plan` | Read the structured execution plan (`plan.json`) |
+| `update_plan` | Update a plan step's status (`pending`/`in_progress`/`done`/`skipped`) |
| `task_done` | Signal task completion |
| `write_todos` | Update internal todo list |
| `compact_conversation` | Trigger conversation compaction |
### Permissions
-Control which tools can access which paths:
+By default **all paths are read-only** — `ls`, `read_file`, `glob`, and `grep` work everywhere. Write operations (`write_file`, `edit_file`, `execute`) are only allowed on paths matching `writable_paths`:
+
+```json
+{
+ "writable_paths": ["./src/*", "./tests/*", "/tmp/out/*"]
+}
+```
+
+Patterns use glob syntax (`**` for recursive, `*` for single-level, `?` for one char). For advanced allow/deny rules, the legacy `permissions` array is still supported as a fallback:
```json
{
+ "writable_paths": ["./src/*"],
"permissions": [
- { "operations": ["read"], "paths": ["./*"], "mode": "allow" },
- { "operations": ["write"], "paths": ["./src/*"], "mode": "allow" },
{ "operations": ["write"], "paths": ["./secrets/*"], "mode": "deny" }
]
}
```
-Operations: `"read"` covers `ls`, `read_file`, `glob`, `grep`. `"write"` covers `write_file`, `edit_file`, `execute`. Paths support `/*` for prefix matching.
+`writable_paths` can also be set via the `--writable-paths` CLI flag (comma-separated); CLI values are added on top of config values.
## Output
@@ -216,7 +228,7 @@ After execution, the output directory contains:
| File | Description |
|------|-------------|
| `run.json` | Detailed run log with per-iteration and per-tool TAT, errors, and exceptions |
-| `plan.md` | Generated execution plan |
+| `plan.json` | Structured execution plan (steps with status, readable/writable by the agent) |
| `report.json` | Status, token usage, iterations, duration, todos |
| `transcript.json` | Full message history |
| `trace.jsonl` | Structured event log (one JSON object per line) |
diff --git a/agent-runner/Cargo.lock b/agent-runner/Cargo.lock
index 3a3cb7d..b21f025 100644
--- a/agent-runner/Cargo.lock
+++ b/agent-runner/Cargo.lock
@@ -9,7 +9,9 @@ dependencies = [
"async-trait",
"chrono",
"clap",
- "glob",
+ "dotenvy",
+ "globset",
+ "ignore",
"lettre",
"regex",
"reqwest",
@@ -132,6 +134,16 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
+[[package]]
+name = "bstr"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f"
+dependencies = [
+ "memchr",
+ "serde_core",
+]
+
[[package]]
name = "bumpalo"
version = "3.20.2"
@@ -245,6 +257,31 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+[[package]]
+name = "crossbeam-deque"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
+
[[package]]
name = "displaydoc"
version = "0.2.5"
@@ -256,6 +293,12 @@ dependencies = [
"syn",
]
+[[package]]
+name = "dotenvy"
+version = "0.15.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
+
[[package]]
name = "email-encoding"
version = "0.4.1"
@@ -417,10 +460,17 @@ dependencies = [
]
[[package]]
-name = "glob"
-version = "0.3.3"
+name = "globset"
+version = "0.4.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988"
+dependencies = [
+ "aho-corasick",
+ "bstr",
+ "log",
+ "regex-automata",
+ "regex-syntax",
+]
[[package]]
name = "h2"
@@ -728,6 +778,22 @@ dependencies = [
"icu_properties",
]
+[[package]]
+name = "ignore"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f"
+dependencies = [
+ "crossbeam-deque",
+ "globset",
+ "log",
+ "memchr",
+ "regex-automata",
+ "same-file",
+ "walkdir",
+ "winapi-util",
+]
+
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -1074,9 +1140,9 @@ dependencies = [
[[package]]
name = "regex-automata"
-version = "0.4.14"
+version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
@@ -1201,6 +1267,15 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
[[package]]
name = "schannel"
version = "0.1.29"
@@ -1703,6 +1778,16 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
[[package]]
name = "want"
version = "0.3.1"
@@ -1835,6 +1920,15 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "windows-core"
version = "0.62.2"
diff --git a/agent-runner/Cargo.toml b/agent-runner/Cargo.toml
index bf034c9..2246240 100644
--- a/agent-runner/Cargo.toml
+++ b/agent-runner/Cargo.toml
@@ -9,7 +9,8 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }
-glob = "0.3"
+globset = "0.4"
+ignore = "0.4"
regex = "1"
lettre = "0.11"
chrono = "0.4"
diff --git a/agent-runner/src/agent/planner.rs b/agent-runner/src/agent/planner.rs
index d7633de..afa9267 100644
--- a/agent-runner/src/agent/planner.rs
+++ b/agent-runner/src/agent/planner.rs
@@ -1,7 +1,25 @@
use crate::provider::{Message, Provider};
use crate::trace::TraceLogger;
+use chrono::Utc;
+use serde::{Deserialize, Serialize};
use std::sync::Arc;
+/// A single step in an execution plan.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PlanStep {
+ pub id: u32,
+ pub description: String,
+ pub status: String,
+}
+
+/// A structured execution plan, persisted as `plan.json`.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Plan {
+ pub task: String,
+ pub created_at: String,
+ pub steps: Vec,
+}
+
pub struct Planner {
provider: Arc,
trace: Arc,
@@ -47,10 +65,38 @@ impl Planner {
Ok(plan)
}
- pub fn save_plan(plan: &str, output_dir: &std::path::Path) -> Result<(), String> {
+ /// Parse the free-form plan text returned by the LLM into a structured `Plan`.
+ /// Each non-empty line becomes a step with an incrementing id (starting at 1)
+ /// and a default status of "pending".
+ pub fn parse_plan(plan_text: &str, task: &str) -> Plan {
+ let steps: Vec = plan_text
+ .lines()
+ .map(|l| l.trim())
+ .filter(|l| !l.is_empty())
+ .enumerate()
+ .map(|(i, line)| PlanStep {
+ id: (i + 1) as u32,
+ description: line.trim_start_matches(|c| c == '-' || c == '*' || c == ' ' || c == '\t')
+ .trim()
+ .to_string(),
+ status: "pending".to_string(),
+ })
+ .collect();
+
+ Plan {
+ task: task.to_string(),
+ created_at: Utc::now().to_rfc3339(),
+ steps,
+ }
+ }
+
+ /// Persist the structured plan as `plan.json` in the output directory.
+ pub fn save_plan_json(plan: &Plan, output_dir: &std::path::Path) -> Result<(), String> {
std::fs::create_dir_all(output_dir)
.map_err(|e| format!("Failed to create output dir: {}", e))?;
- std::fs::write(output_dir.join("plan.md"), plan)
- .map_err(|e| format!("Failed to write plan: {}", e))
+ let json = serde_json::to_string_pretty(plan)
+ .map_err(|e| format!("Failed to serialize plan: {}", e))?;
+ std::fs::write(output_dir.join("plan.json"), json)
+ .map_err(|e| format!("Failed to write plan.json: {}", e))
}
}
diff --git a/agent-runner/src/config.rs b/agent-runner/src/config.rs
index 971771f..07fc558 100644
--- a/agent-runner/src/config.rs
+++ b/agent-runner/src/config.rs
@@ -10,6 +10,8 @@ pub struct Config {
#[serde(default)]
pub permissions: Vec,
#[serde(default)]
+ pub writable_paths: Vec,
+ #[serde(default)]
pub subagents: Vec,
#[serde(default)]
pub agent: AgentConfig,
@@ -220,6 +222,7 @@ impl Default for Config {
mcp_servers: HashMap::new(),
summarization: SummarizationConfig::default(),
permissions: Vec::new(),
+ writable_paths: Vec::new(),
subagents: Vec::new(),
agent: AgentConfig::default(),
timeouts: TimeoutConfig::default(),
diff --git a/agent-runner/src/main.rs b/agent-runner/src/main.rs
index 461f7b9..0811a2b 100644
--- a/agent-runner/src/main.rs
+++ b/agent-runner/src/main.rs
@@ -14,6 +14,7 @@ use summarization::Summarizer;
use tools::compact::CompactTool;
use tools::done::TaskDoneTool;
use tools::filesystem::create_filesystem_tools;
+use tools::plan::{ReadPlanTool, UpdatePlanTool};
use tools::todos::TodosTool;
pub mod agent;
@@ -60,6 +61,9 @@ pub struct Cli {
#[arg(long, default_value_t = false)]
pub sandbox: bool,
+ #[arg(long, value_delimiter = ',')]
+ pub writable_paths: Option>,
+
#[arg(long, default_value_t = 120, value_name = "SECONDS")]
pub tool_timeout: u64,
@@ -113,7 +117,15 @@ async fn main() {
}
});
- let evaluator = PermissionEvaluator::new(agent_dir.config.permissions.clone());
+ let mut writable_paths = agent_dir.config.writable_paths.clone();
+ if let Some(cli_wp) = &cli.writable_paths {
+ writable_paths.extend(cli_wp.iter().cloned());
+ }
+ let evaluator = PermissionEvaluator::new(
+ agent_dir.config.permissions.clone(),
+ writable_paths,
+ cli.working_dir.clone(),
+ );
let compact_tool = Arc::new(CompactTool::new());
let todos_tool = Arc::new(TodosTool::new());
@@ -131,6 +143,10 @@ async fn main() {
tools.push(Box::new(TaskDoneTool));
tools.push(Box::new(tools::compact::CompactTool::new()));
+ let plan_path = cli.output_dir.join("plan.json");
+ tools.push(Box::new(ReadPlanTool::new(plan_path.clone())));
+ tools.push(Box::new(UpdatePlanTool::new(plan_path.clone())));
+
let skill_tools = tools::skill_tool::from_skills(&agent_dir.skills);
tools.extend(skill_tools);
@@ -161,36 +177,40 @@ async fn main() {
}
}
- let plan = if agent_dir.config.agent.plan_required {
+ let plan_text = if agent_dir.config.agent.plan_required {
let planner = Planner::new(provider.clone(), trace.clone());
let all_tool_names: Vec = tools.iter().map(|t| t.name().to_string()).collect();
- let plan = planner
+ let raw_plan = planner
.generate_plan(&system_prompt, &prompt_text, &all_tool_names)
.await
.unwrap_or_else(|e| {
run_logger.add_error(0, "planning", &e);
String::new()
});
- Planner::save_plan(&plan, &cli.output_dir)
+ let plan = Planner::parse_plan(&raw_plan, &prompt_text);
+ Planner::save_plan_json(&plan, &cli.output_dir)
.unwrap_or_else(|e| {
run_logger.add_error(0, "planning", &format!("Failed to save plan: {}", e));
eprintln!("Warning: {}", e)
});
- plan
+ raw_plan
} else {
String::new()
};
if cli.plan_only {
- println!("{}", plan);
+ match std::fs::read_to_string(cli.output_dir.join("plan.json")) {
+ Ok(content) => println!("{}", content),
+ Err(_) => println!("{}", plan_text),
+ }
std::process::exit(0);
}
let mut messages = vec![provider::Message::system(system_prompt)];
- if !plan.is_empty() {
+ if !plan_text.is_empty() {
messages.push(provider::Message::system(format!(
- "[Execution Plan]\n{}",
- plan
+ "[Execution Plan]\n{}\n\nUse the read_plan tool to review the plan and its step statuses, and update_plan to mark steps as in_progress, done, or skipped as you progress.",
+ plan_text
)));
}
messages.push(provider::Message::user(prompt_text.clone()));
@@ -246,7 +266,7 @@ async fn main() {
result.status.clone(),
result.exit_code,
prompt_text.clone(),
- cli.output_dir.join("plan.md").to_string_lossy().into_owned(),
+ cli.output_dir.join("plan.json").to_string_lossy().into_owned(),
todos_final,
0,
Metrics {
diff --git a/agent-runner/src/permissions.rs b/agent-runner/src/permissions.rs
index 1be6fec..1869454 100644
--- a/agent-runner/src/permissions.rs
+++ b/agent-runner/src/permissions.rs
@@ -1,15 +1,92 @@
use crate::config::FilesystemPermission;
+use globset::{Glob, GlobSet, GlobSetBuilder};
+use std::path::{Component, PathBuf};
+
+/// Normalize a path string by removing `.` components so that joined
+/// relative paths (e.g. `/work/./src/**`) become canonical (`/work/src/**`).
+/// This is necessary for globset matching to work consistently.
+fn normalize(path: &str) -> String {
+ let p = PathBuf::from(path);
+ let mut out = PathBuf::new();
+ for comp in p.components() {
+ match comp {
+ Component::CurDir => {}
+ other => out.push(other.as_os_str()),
+ }
+ }
+ out.to_string_lossy().into_owned()
+}
pub struct PermissionEvaluator {
rules: Vec,
+ writable_globs: GlobSet,
+ working_dir: PathBuf,
}
impl PermissionEvaluator {
- pub fn new(rules: Vec) -> Self {
- Self { rules }
+ pub fn new(
+ rules: Vec,
+ writable_paths: Vec,
+ working_dir: PathBuf,
+ ) -> Self {
+ let normalized_wd = normalize(&working_dir.to_string_lossy());
+ let mut builder = GlobSetBuilder::new();
+ for pattern in &writable_paths {
+ // Resolve relative patterns against the working directory so they
+ // match absolute paths consistently. Strip a leading "./" so the
+ // joined path doesn't contain a spurious "." component.
+ let clean = pattern.strip_prefix("./").unwrap_or(pattern);
+ let resolved_pattern = if PathBuf::from(pattern).is_absolute() {
+ normalize(pattern)
+ } else {
+ format!("{}/{}", normalized_wd, clean)
+ };
+ if let Ok(glob) = Glob::new(&resolved_pattern) {
+ builder.add(glob);
+ }
+ }
+ let writable_globs = builder.build().unwrap_or_else(|_| GlobSet::empty());
+
+ Self {
+ rules,
+ writable_globs,
+ working_dir: PathBuf::from(normalized_wd),
+ }
+ }
+
+ /// Resolve a (possibly relative) path against the working directory so
+ /// that all permission checks operate on canonical absolute paths.
+ fn resolve(&self, path: &str) -> String {
+ let p = PathBuf::from(path);
+ let resolved = if p.is_absolute() {
+ normalize(path)
+ } else {
+ normalize(&format!("{}/{}", self.working_dir.to_string_lossy(), path))
+ };
+ resolved
}
pub fn check(&self, operation: &str, path: &str) -> bool {
+ // Read operations are allowed everywhere by default.
+ if operation == "read" {
+ return true;
+ }
+
+ // Write operations: check writable_paths first, then fall back to the
+ // explicit permissions array for backwards compatibility.
+ let resolved = self.resolve(path);
+
+ // 1. writable_paths whitelist (globset match).
+ if self.writable_globs.is_match(&resolved) {
+ return true;
+ }
+ // Also try matching the raw (unresolved) path, since a user may
+ // configure writable_paths with relative patterns.
+ if path != resolved && self.writable_globs.is_match(path) {
+ return true;
+ }
+
+ // 2. Explicit permissions rules (legacy / advanced).
for rule in &self.rules {
if !rule.operations.iter().any(|op| op == operation) {
continue;
@@ -18,9 +95,9 @@ impl PermissionEvaluator {
let path_matches = rule.paths.iter().any(|pattern| {
if pattern.ends_with("/*") {
let prefix = &pattern[..pattern.len() - 2];
- path == prefix || path.starts_with(&format!("{}/", prefix))
+ resolved == prefix || resolved.starts_with(&format!("{}/", prefix))
} else {
- path == pattern
+ resolved == *pattern || path == *pattern
}
});
@@ -28,6 +105,63 @@ impl PermissionEvaluator {
return rule.mode == "allow";
}
}
+
+ // 3. Default-deny for writes.
false
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn evaluator(writable: Vec<&str>, working_dir: &str) -> PermissionEvaluator {
+ PermissionEvaluator::new(
+ Vec::new(),
+ writable.into_iter().map(String::from).collect(),
+ PathBuf::from(working_dir),
+ )
+ }
+
+ #[test]
+ fn read_always_allowed() {
+ let e = evaluator(vec![], "/work");
+ assert!(e.check("read", "/etc/passwd"));
+ assert!(e.check("read", "anything"));
+ }
+
+ #[test]
+ fn write_denied_by_default() {
+ let e = evaluator(vec![], "/work");
+ assert!(!e.check("write", "/work/src/main.rs"));
+ }
+
+ #[test]
+ fn write_allowed_in_writable_paths() {
+ let e = evaluator(vec!["/work/src/**"], "/work");
+ assert!(e.check("write", "/work/src/main.rs"));
+ assert!(e.check("write", "/work/src/nested/deep.rs"));
+ assert!(!e.check("write", "/work/other.rs"));
+ }
+
+ #[test]
+ fn write_allowed_with_relative_pattern() {
+ let e = evaluator(vec!["./src/**"], "/work");
+ // Resolved absolute path should match.
+ assert!(e.check("write", "src/main.rs"));
+ assert!(e.check("write", "/work/src/main.rs"));
+ assert!(!e.check("write", "/work/out/main.rs"));
+ }
+
+ #[test]
+ fn legacy_permissions_still_work() {
+ let rules = vec![FilesystemPermission {
+ operations: vec!["write".into()],
+ paths: vec!["/work/out/*".into()],
+ mode: "allow".into(),
+ }];
+ let e = PermissionEvaluator::new(rules, vec![], PathBuf::from("/work"));
+ assert!(e.check("write", "/work/out/file.txt"));
+ assert!(!e.check("write", "/work/secret.txt"));
+ }
+}
diff --git a/agent-runner/src/tools/filesystem.rs b/agent-runner/src/tools/filesystem.rs
index 9e743fc..3d15813 100644
--- a/agent-runner/src/tools/filesystem.rs
+++ b/agent-runner/src/tools/filesystem.rs
@@ -265,12 +265,12 @@ impl Tool for GlobTool {
fn definition(&self) -> ToolDefinition {
ToolDefinition {
name: "glob".into(),
- description: "Find files matching a glob pattern.".into(),
+ description: "Find files matching a glob pattern. Respects .gitignore and skips hidden files by default.".into(),
parameters: serde_json::json!({
"type": "object",
"properties": {
- "pattern": { "type": "string", "description": "Glob pattern to match" },
- "path": { "type": "string", "description": "Base directory to search in", "default": "/" }
+ "pattern": { "type": "string", "description": "Glob pattern to match (e.g. **/*.rs, src/*.ts)" },
+ "path": { "type": "string", "description": "Base directory to search in", "default": "." }
},
"required": ["pattern"]
}),
@@ -279,34 +279,89 @@ impl Tool for GlobTool {
async fn execute(&self, args: serde_json::Value) -> ToolOutput {
let pattern = args["pattern"].as_str().unwrap_or("");
- let base_path = args["path"].as_str().unwrap_or("/");
+ let base_path = args["path"].as_str().unwrap_or(".");
let base = resolve_path(&self.working_dir, base_path);
- let full_pattern = if pattern.starts_with('/') {
- pattern.to_string()
- } else {
- base.join(pattern).to_string_lossy().into_owned()
- };
+ if pattern.is_empty() {
+ return ToolOutput {
+ content: "Pattern must not be empty".into(),
+ is_error: true,
+ };
+ }
- match glob::glob(&full_pattern) {
- Ok(paths) => {
- let results: Vec = paths
- .filter_map(|p| p.ok())
- .map(|p| p.to_string_lossy().into_owned())
- .collect();
- ToolOutput {
- content: if results.is_empty() {
- "No matches found".into()
- } else {
- results.join("\n")
- },
- is_error: false,
+ // Compile the user pattern into a GlobSet matcher.
+ let glob = match globset::Glob::new(pattern) {
+ Ok(g) => g,
+ Err(e) => {
+ return ToolOutput {
+ content: format!("Invalid glob pattern: {}", e),
+ is_error: true,
}
}
- Err(e) => ToolOutput {
- content: format!("Invalid glob pattern: {}", e),
+ };
+ let matcher = match globset::GlobSetBuilder::new().add(glob).build() {
+ Ok(gs) => gs,
+ Err(e) => {
+ return ToolOutput {
+ content: format!("Failed to compile glob set: {}", e),
+ is_error: true,
+ }
+ }
+ };
+
+ // If the base path is a single file, just test it directly.
+ if base.is_file() {
+ let rel = base.strip_prefix(&self.working_dir).unwrap_or(&base);
+ let matched = matcher.is_match(base.as_path())
+ || matcher.is_match(rel);
+ let content = if matched {
+ base.to_string_lossy().into_owned()
+ } else {
+ "No matches found".into()
+ };
+ return ToolOutput { content, is_error: false };
+ }
+
+ if !base.is_dir() {
+ return ToolOutput {
+ content: format!("Path not found: {}", base.display()),
is_error: true,
+ };
+ }
+
+ // Walk the directory tree with .gitignore / hidden-file awareness.
+ let walker = ignore::WalkBuilder::new(&base)
+ .hidden(true)
+ .git_ignore(true)
+ .git_exclude(true)
+ .git_global(true)
+ .build();
+
+ let mut results: Vec = Vec::new();
+ for entry in walker.flatten() {
+ if results.len() >= 1000 {
+ results.push("... (truncated at 1000 results)".into());
+ break;
+ }
+ let path = entry.path();
+ if !path.is_file() {
+ continue;
+ }
+ // Match against both the absolute path and the path relative to base.
+ let rel = path.strip_prefix(&base).unwrap_or(path);
+ if matcher.is_match(path) || matcher.is_match(rel) {
+ results.push(path.to_string_lossy().into_owned());
+ }
+ }
+
+ ToolOutput {
+ content: if results.is_empty() {
+ "No matches found".into()
+ } else {
+ results.sort();
+ results.join("\n")
},
+ is_error: false,
}
}
}
@@ -324,13 +379,13 @@ impl Tool for GrepTool {
fn definition(&self) -> ToolDefinition {
ToolDefinition {
name: "grep".into(),
- description: "Search file contents using a regex pattern.".into(),
+ description: "Search file contents using a regex pattern. Respects .gitignore and skips hidden files by default.".into(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"pattern": { "type": "string", "description": "Regex pattern to search for" },
- "path": { "type": "string", "description": "Directory or file to search in" },
- "glob": { "type": "string", "description": "File glob pattern to filter files" }
+ "path": { "type": "string", "description": "Directory or file to search in", "default": "." },
+ "glob": { "type": "string", "description": "File glob pattern to filter files (e.g. *.rs)" }
},
"required": ["pattern"]
}),
@@ -353,12 +408,52 @@ impl Tool for GrepTool {
};
let search_path = resolve_path(&self.working_dir, path_str);
+
+ // Optional file-name glob filter.
+ let file_filter = if let Some(gp) = glob_pattern {
+ match globset::Glob::new(gp) {
+ Ok(g) => Some(g.compile_matcher()),
+ Err(_) => None,
+ }
+ } else {
+ None
+ };
+
let mut results: Vec = Vec::new();
+ let mut truncated = false;
if search_path.is_file() {
- search_file(&search_path, &re, &mut results);
+ search_file(&search_path, &re, &mut results, 1000);
} else if search_path.is_dir() {
- search_dir(&search_path, &re, glob_pattern, &mut results);
+ // Walk with .gitignore / hidden-file awareness.
+ let walker = ignore::WalkBuilder::new(&search_path)
+ .hidden(true)
+ .git_ignore(true)
+ .git_exclude(true)
+ .git_global(true)
+ .build();
+
+ for entry in walker.flatten() {
+ let path = entry.path();
+ if !path.is_file() {
+ continue;
+ }
+ // Apply optional glob filter on the file name.
+ if let Some(ref filter) = file_filter {
+ if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
+ if !filter.is_match(name) {
+ continue;
+ }
+ } else {
+ continue;
+ }
+ }
+ search_file(path, &re, &mut results, 1000);
+ if results.len() >= 1000 {
+ truncated = true;
+ break;
+ }
+ }
} else {
return ToolOutput {
content: format!("Path not found: {}", search_path.display()),
@@ -366,6 +461,10 @@ impl Tool for GrepTool {
};
}
+ if truncated {
+ results.push("... (truncated at 1000 matches)".into());
+ }
+
ToolOutput {
content: if results.is_empty() {
"No matches found".into()
@@ -377,41 +476,20 @@ impl Tool for GrepTool {
}
}
-fn search_file(path: &std::path::Path, re: ®ex::Regex, results: &mut Vec) {
+fn search_file(
+ path: &std::path::Path,
+ re: ®ex::Regex,
+ results: &mut Vec,
+ limit: usize,
+) {
if let Ok(content) = std::fs::read_to_string(path) {
for (i, line) in content.lines().enumerate() {
if re.is_match(line) {
results.push(format!("{}:{}: {}", path.display(), i + 1, line));
- }
- }
- }
-}
-
-fn search_dir(
- dir: &std::path::Path,
- re: ®ex::Regex,
- glob_pattern: Option<&str>,
- results: &mut Vec,
-) {
- let entries = match std::fs::read_dir(dir) {
- Ok(e) => e,
- Err(_) => return,
- };
-
- for entry in entries.filter_map(|e| e.ok()) {
- let path = entry.path();
- if path.is_dir() {
- search_dir(&path, re, glob_pattern, results);
- } else if let Some(gp) = glob_pattern {
- if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
- if let Ok(pat) = glob::Pattern::new(gp) {
- if pat.matches(name) {
- search_file(&path, re, results);
- }
+ if results.len() >= limit {
+ return;
}
}
- } else {
- search_file(&path, re, results);
}
}
}
diff --git a/agent-runner/src/tools/mod.rs b/agent-runner/src/tools/mod.rs
index dc57c2a..a2c3859 100644
--- a/agent-runner/src/tools/mod.rs
+++ b/agent-runner/src/tools/mod.rs
@@ -5,6 +5,7 @@ pub mod compact;
pub mod done;
pub mod execute;
pub mod filesystem;
+pub mod plan;
pub mod skill_tool;
pub mod subagent;
pub mod todos;
diff --git a/agent-runner/src/tools/plan.rs b/agent-runner/src/tools/plan.rs
new file mode 100644
index 0000000..63553e5
--- /dev/null
+++ b/agent-runner/src/tools/plan.rs
@@ -0,0 +1,186 @@
+use async_trait::async_trait;
+use crate::agent::planner::Plan;
+use crate::provider::ToolDefinition;
+use crate::tools::{Tool, ToolOutput};
+use std::path::PathBuf;
+
+/// Tool that reads the structured `plan.json` so the agent can review the
+/// current plan and the status of each step during the execution loop.
+pub struct ReadPlanTool {
+ plan_path: PathBuf,
+}
+
+impl ReadPlanTool {
+ pub fn new(plan_path: PathBuf) -> Self {
+ Self { plan_path }
+ }
+}
+
+#[async_trait]
+impl Tool for ReadPlanTool {
+ fn name(&self) -> &str {
+ "read_plan"
+ }
+
+ fn definition(&self) -> ToolDefinition {
+ ToolDefinition {
+ name: "read_plan".into(),
+ description: "Read the execution plan (plan.json) including all steps and their current status.".into(),
+ parameters: serde_json::json!({
+ "type": "object",
+ "properties": {},
+ "required": []
+ }),
+ }
+ }
+
+ async fn execute(&self, _args: serde_json::Value) -> ToolOutput {
+ match std::fs::read_to_string(&self.plan_path) {
+ Ok(content) => ToolOutput {
+ content,
+ is_error: false,
+ },
+ Err(_) => ToolOutput {
+ content: "No plan found. A plan is generated at startup when plan_required is true.".into(),
+ is_error: false,
+ },
+ }
+ }
+}
+
+/// Tool that updates a single step's status (and optionally description) in
+/// `plan.json`. Lets the agent track progress as it works through the plan.
+pub struct UpdatePlanTool {
+ plan_path: PathBuf,
+}
+
+impl UpdatePlanTool {
+ pub fn new(plan_path: PathBuf) -> Self {
+ Self { plan_path }
+ }
+}
+
+#[async_trait]
+impl Tool for UpdatePlanTool {
+ fn name(&self) -> &str {
+ "update_plan"
+ }
+
+ fn definition(&self) -> ToolDefinition {
+ ToolDefinition {
+ name: "update_plan".into(),
+ description: "Update the status of a plan step in plan.json. Use this to mark steps as in_progress, done, or skipped as you work.".into(),
+ parameters: serde_json::json!({
+ "type": "object",
+ "properties": {
+ "step_id": { "type": "integer", "description": "The id of the step to update" },
+ "status": { "type": "string", "enum": ["pending", "in_progress", "done", "skipped"], "description": "New status for the step" },
+ "description": { "type": "string", "description": "Optional: update the step description" }
+ },
+ "required": ["step_id", "status"]
+ }),
+ }
+ }
+
+ async fn execute(&self, args: serde_json::Value) -> ToolOutput {
+ let step_id = match args["step_id"].as_u64() {
+ Some(id) => id as u32,
+ None => {
+ return ToolOutput {
+ content: "Missing or invalid 'step_id'".into(),
+ is_error: true,
+ }
+ }
+ };
+ let status = match args["status"].as_str() {
+ Some(s) => s.to_string(),
+ None => {
+ return ToolOutput {
+ content: "Missing or invalid 'status'".into(),
+ is_error: true,
+ }
+ }
+ };
+
+ // Validate status value.
+ let valid = ["pending", "in_progress", "done", "skipped"];
+ if !valid.contains(&status.as_str()) {
+ return ToolOutput {
+ content: format!(
+ "Invalid status '{}'. Must be one of: {}",
+ status,
+ valid.join(", ")
+ ),
+ is_error: true,
+ };
+ }
+
+ let new_description = args["description"].as_str().map(|s| s.to_string());
+
+ // Read current plan.
+ let content = match std::fs::read_to_string(&self.plan_path) {
+ Ok(c) => c,
+ Err(_) => {
+ return ToolOutput {
+ content: "No plan found. Cannot update a non-existent plan.".into(),
+ is_error: true,
+ }
+ }
+ };
+
+ let mut plan: Plan = match serde_json::from_str(&content) {
+ Ok(p) => p,
+ Err(e) => {
+ return ToolOutput {
+ content: format!("Failed to parse plan.json: {}", e),
+ is_error: true,
+ }
+ }
+ };
+
+ // Find and update the matching step.
+ let mut found = None;
+ for step in &mut plan.steps {
+ if step.id == step_id {
+ step.status = status.clone();
+ if let Some(desc) = &new_description {
+ step.description = desc.clone();
+ }
+ found = Some(step.clone());
+ break;
+ }
+ }
+
+ let updated_step = match found {
+ Some(s) => s,
+ None => {
+ return ToolOutput {
+ content: format!("Step with id {} not found in plan", step_id),
+ is_error: true,
+ }
+ }
+ };
+
+ // Write back.
+ let json = match serde_json::to_string_pretty(&plan) {
+ Ok(j) => j,
+ Err(e) => {
+ return ToolOutput {
+ content: format!("Failed to serialize updated plan: {}", e),
+ is_error: true,
+ }
+ }
+ };
+ if let Err(e) = std::fs::write(&self.plan_path, json) {
+ return ToolOutput {
+ content: format!("Failed to write plan.json: {}", e),
+ is_error: true,
+ };
+ }
+
+ ToolOutput {
+ content: serde_json::to_string_pretty(&updated_step).unwrap_or_default(),
+ is_error: false,
+ }
+ }
+}
diff --git a/web-site/index.html b/web-site/index.html
index 27348c2..1d33d4d 100644
--- a/web-site/index.html
+++ b/web-site/index.html
@@ -357,7 +357,7 @@ Multi-Provider LLM
Planning Phase
-
Generates a step-by-step execution plan before acting. Preview with --plan-only.
+
Generates a structured plan.json before acting. Agent tracks step status with read_plan & update_plan.
@@ -372,12 +372,12 @@
Summarization
Filesystem Tools
-
ls, read, write, edit, glob, grep. Everything an agent needs to work with code.
+
ls, read, write, edit, glob, grep. Built on ripgrep's ignore engine — respects .gitignore.
Permission System
-
Allow/deny rules for read/write on specific paths. Sandboxed by default.
+
Read-only by default. Only writable_paths allow writes. Simple glob-based whitelist.
@@ -479,9 +479,8 @@
What goes where
"plan_required":
true,
"execute_enabled":
false
},
-
"permissions":
[
-
{ "operations":
["read"],
"paths":
["./*"],
"mode":
"allow" }
-
]
+
"writable_paths":
["./src/*",
"/tmp/out/*"],
+
"permissions":
[]
}
@@ -584,6 +583,8 @@
What goes where
globfind files by pattern
grepsearch with regex
executerun shell commands
+
read_planread plan.json
+
update_planupdate step status
task_donesignal completion
write_todostrack task list
compactshrink context