feat(users): UserManager with per-user SQLCipher, and extract skald-core crate

Two changes developed together in one session; they share the same module
structure (db/mod.rs, the core lib root) and only compile together, so they
land as one commit.

## UserManager + per-user encryption (§9/§11)

New `users::UserManager`: owns the system.db pool plus a map
`userid -> SqlitePool` of unlocked databases. The pool *is* the unlock token —
its connect options carry the DEK as SQLCipher's raw key, so an open pool means
the key is in RAM until restart and dropping it re-locks (§9). Knows nothing
about cookies.

New `crypto` module: envelope encryption. A random 256-bit DEK encrypts
`{userid}.db`; `users.database_password` holds it sealed with AES-256-GCM under
`Argon2id(password, salt)`. The AEAD tag is the password verifier — one
derivation both authenticates and yields the key, so encrypted users store no
second hash. Cleartext users store the Argon2id output directly, compared in
constant time. Argon2 runs in spawn_blocking behind a 2-permit semaphore
(256 MiB per derivation).

- SQLCipher via `libsqlite3-sys` `bundled-sqlcipher-vendored-openssl`, pinned
  <0.38 so it unifies with the one sqlx-sqlite links (a newer copy would apply
  the feature to a SQLite sqlx never uses). OpenSSL is vendored and static, so
  the binary stays self-contained.
- Schema split into `create_registry_tables` (instance-wide, no user key) and
  `create_owner_tables` (one owner's content, identical in every file). No FK in
  the owner bucket may reach the registry — enforced by a standalone test.
  Dropped `chat_history.model_db_id` (write-only, and the only registry-crossing
  key); moved `projects`/`project_tickets` into the owner bucket.
- Provisioning invariant: the file is written before the row, deleted after it,
  so a crash leaves an orphan file, never a user without a database. `open_db`
  never creates: a missing file is an error, not a silent empty database.

Not consumed yet: no login, call sites still use the shared system.db pool.

## Extract crates/skald-core

The headless core moves out of `src/` into its own crate; `skald` (server) and
the coming `skald-setup` are shells around it. Two dependencies on the shell
were inverted rather than dragged along, so the core names neither Tauri nor any
concrete plugin:

- `Plugin::tools(self: Arc<Self>)` — plugins contribute tools through this hook
  (sibling of `http_router`), so the core no longer downcasts to
  `MobileConnectorPlugin`.
- `tools::restart::set_restart_handler` — the desktop shell installs its
  teardown-and-respawn; the core defaults to the supervisor exit code. The core
  loses its `desktop` feature.
- `boot`'s stdout formatter moves to the binary (`src/boot_format.rs`); the core
  only emits tracing events.

All 79 core tests pass; the binary boots and serves in a clean directory, and
the mobile-connector tools still register through the new hook.
This commit is contained in:
2026-07-10 16:48:51 +01:00
parent 38494a85a9
commit 178a38357e
173 changed files with 2650 additions and 1106 deletions
+148
View File
@@ -0,0 +1,148 @@
use anyhow::Result;
use serde_json::{Value, json};
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT};
use super::{read_to_string, write_string};
fn normalize_ws(s: &str) -> String {
s.lines()
.map(|l| {
let mut out = String::with_capacity(l.len());
let mut last_space = true;
for ch in l.chars() {
if ch.is_whitespace() {
if !last_space { out.push(' '); }
last_space = true;
} else {
out.push(ch);
last_space = false;
}
}
out.trim_end().to_owned()
})
.collect::<Vec<_>>()
.join("\n")
}
fn find_normalized(haystack: &str, normalized_needle: &str) -> Option<(usize, usize)> {
let needle_lines: Vec<&str> = normalized_needle.lines().collect();
let n = needle_lines.len();
if n == 0 { return None; }
let hay_lines: Vec<&str> = haystack.lines().collect();
let hay_count = hay_lines.len();
let mut offsets = Vec::with_capacity(hay_count + 1);
offsets.push(0usize);
for line in &hay_lines {
let prev = *offsets.last().unwrap();
offsets.push(prev + line.len() + 1);
}
for start_idx in 0..=(hay_count.saturating_sub(n)) {
let matches = (0..n).all(|i| {
normalize_ws(hay_lines[start_idx + i]).as_str() == needle_lines[i]
});
if matches {
let byte_start = offsets[start_idx];
let byte_end = if start_idx + n < hay_count {
offsets[start_idx + n]
} else {
haystack.len()
};
return Some((byte_start, byte_end));
}
}
None
}
pub struct EditFile;
impl EditFile {
pub fn new() -> Self { Self }
}
impl Tool for EditFile {
fn name(&self) -> &str { "edit_file" }
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
fn description(&self) -> &str {
"Replace a substring in a file with new text. \
Use instead of sed/awk in the terminal. \
Relative paths are resolved from the project root; absolute paths (starting with /) are used as-is. \
By default `old` must be unique — include enough surrounding context to make it so. \
Always call read_file first and copy text exactly as shown after '| ' (the ' N | ' prefix is NOT part of the file). \
Set replace_all=true to replace every occurrence instead of requiring uniqueness."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "File path. Relative to project root, or absolute." },
"old": { "type": "string", "description": "Text to find and replace. Must be unique in the file unless replace_all=true." },
"new": { "type": "string", "description": "Replacement text. Pass empty string to delete the matched text." },
"replace_all": {
"type": "boolean",
"description": "Replace every occurrence of old instead of requiring a unique match (default: false).",
"default": false
}
},
"required": ["path", "old", "new"]
})
}
fn target_path(&self, args: &Value) -> Option<String> {
super::path_arg(args)
}
fn describe(&self, args: &Value, length: ToolDescriptionLength) -> String {
let path = args["path"].as_str().unwrap_or("?");
let _ = length;
truncate_label(&format!("edit_file `{path}`"), MAX_LABEL_SHORT)
}
fn execute(&self, args: Value) -> Result<String> {
let user_path = args["path"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
let old = args["old"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: old"))?;
let new = args["new"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: new"))?;
let replace_all = args["replace_all"].as_bool().unwrap_or(false);
let content = read_to_string(user_path)?;
let updated = if replace_all {
if !content.contains(old) {
anyhow::bail!(
"Text not found in {user_path}. \
Call read_file first and copy the text exactly as shown after the '| ' prefix."
);
}
content.replace(old, new)
} else {
let exact_count = content.matches(old).count();
if exact_count > 1 {
anyhow::bail!(
"Text found {exact_count} times in {user_path}. \
Include more surrounding context in `old` to make it unique, or set replace_all=true."
);
}
if exact_count == 1 {
content.replacen(old, new, 1)
} else {
let normalized_old = normalize_ws(old);
let (start, end) = find_normalized(&content, &normalized_old)
.ok_or_else(|| anyhow::anyhow!(
"Text not found in {user_path}. \
Call read_file first and copy the text exactly as shown after the '| ' prefix."
))?;
format!("{}{}{}", &content[..start], new, &content[end..])
}
};
write_string(user_path, &updated)?;
Ok(format!("Edited {user_path}."))
}
}
@@ -0,0 +1,356 @@
use anyhow::Result;
use regex::Regex;
use serde_json::{Value, json};
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL};
use super::resolve;
pub struct GrepFiles;
impl GrepFiles {
pub fn new() -> Self { Self }
}
impl Tool for GrepFiles {
fn name(&self) -> &str { "grep_files" }
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
fn description(&self) -> &str {
"Search for a regex pattern across files in a directory or a single file. \
Use instead of grep/rg in the terminal. \
Binary files and common build/cache directories (target/, .git/, node_modules/, .venv/) are skipped. \
Use output_mode='files_only' to get just file paths (faster, lower token cost). \
Use output_mode='count' for match counts per file. \
Use context_lines to show surrounding lines around each match (like grep -C)."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Directory or file to search. Relative to project root, or absolute."
},
"pattern": {
"type": "string",
"description": "Regex pattern to search for (case-insensitive by default)."
},
"case_sensitive": {
"type": "boolean",
"description": "If true, match is case-sensitive. Default: false.",
"default": false
},
"include_glob": {
"type": "string",
"description": "Restrict search to files matching this glob pattern, e.g. '*.rs' or '*.py'."
},
"output_mode": {
"type": "string",
"enum": ["content", "files_only", "count"],
"description": "'content' (default): matching lines with file path and line number. 'files_only': only the paths of files containing at least one match — use when you need to know which files match without reading content. 'count': number of matches per file.",
"default": "content"
},
"context_lines": {
"type": "integer",
"description": "Lines of context to show before and after each match in content mode (default 0, max 10). Like grep -C.",
"default": 0
},
"max_results": {
"type": "integer",
"description": "Stop after this many results (default 100).",
"default": 100
},
"offset": {
"type": "integer",
"description": "Skip the first N results for pagination (default 0).",
"default": 0
}
},
"required": ["path", "pattern"]
})
}
fn describe(&self, args: &Value, length: ToolDescriptionLength) -> String {
let pattern = args["pattern"].as_str().unwrap_or("?");
match length {
ToolDescriptionLength::Short => {
truncate_label(&format!("grep_files `{pattern}`"), MAX_LABEL_SHORT)
}
ToolDescriptionLength::Full => {
let path = args["path"].as_str().unwrap_or(".");
truncate_label(&format!("grep_files `{pattern}` in {path}"), MAX_LABEL_FULL)
}
}
}
fn execute(&self, args: Value) -> Result<String> {
let user_path = args["path"].as_str().ok_or_else(|| anyhow::anyhow!("Missing: path"))?;
let pattern = args["pattern"].as_str().ok_or_else(|| anyhow::anyhow!("Missing: pattern"))?;
let case_sensitive = args["case_sensitive"].as_bool().unwrap_or(false);
let include_glob = args["include_glob"].as_str();
let output_mode = args["output_mode"].as_str().unwrap_or("content");
let context_lines = args["context_lines"].as_u64().unwrap_or(0).min(10) as usize;
let max_results = args["max_results"].as_u64().unwrap_or(100) as usize;
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
let re = {
let pat = if case_sensitive { pattern.to_string() } else { format!("(?i){pattern}") };
Regex::new(&pat).map_err(|e| anyhow::anyhow!("Invalid regex: {e}"))?
};
let glob_pattern = include_glob.and_then(|g| glob::Pattern::new(g).ok());
let root = resolve(user_path)?;
if !root.exists() {
anyhow::bail!("Path not found: {user_path}");
}
// Walkers emit absolute paths (the `path` arg is resolved to an absolute working
// directory upstream). Strip the queried root so results are shown relative to it,
// consistent with `list_files` — keeps the model from echoing absolute paths back.
let root_prefix = format!("{}/", root.display());
let rel = |s: String| s.strip_prefix(&root_prefix).map(str::to_string).unwrap_or(s);
match output_mode {
"files_only" => {
let mut files: Vec<String> = Vec::new();
collect_matching_files(&root, &re, &glob_pattern, max_results + offset, &mut files)?;
let files: Vec<String> = files.into_iter().skip(offset).take(max_results).map(rel).collect();
if files.is_empty() {
return Ok(format!("No files match {:?} in {user_path}.", pattern));
}
Ok(format!("{} file(s):\n{}", files.len(), files.join("\n")))
}
"count" => {
let mut counts: Vec<(String, usize)> = Vec::new();
collect_match_counts(&root, &re, &glob_pattern, max_results + offset, &mut counts)?;
let counts: Vec<(String, usize)> = counts.into_iter().skip(offset).take(max_results).collect();
if counts.is_empty() {
return Ok(format!("No matches for {:?} in {user_path}.", pattern));
}
let lines: Vec<String> = counts.into_iter().map(|(f, n)| format!("{}: {n}", rel(f))).collect();
Ok(format!("{} file(s):\n{}", lines.len(), lines.join("\n")))
}
_ => {
let mut matches: Vec<String> = Vec::new();
let mut output_bytes: usize = 0;
let mut truncated = false;
search_path(&root, &re, &glob_pattern, max_results + offset, context_lines, &mut matches, &mut output_bytes, &mut truncated)?;
let matches: Vec<String> = matches.into_iter().skip(offset).take(max_results).map(rel).collect();
if matches.is_empty() {
return Ok(format!("No matches for {:?} in {user_path}.", pattern));
}
let mut out = format!("{} match(es):\n", matches.len());
out.push_str(&matches.join("\n"));
if truncated {
out.push_str(&format!(
"\n\n[Output truncated at {MAX_OUTPUT_BYTES} bytes. Narrow your search with a more specific pattern, path, or include_glob.]"
));
}
Ok(out)
}
}
}
}
// `secrets` is skipped so a recursive grep rooted at a parent (e.g. the auto-read
// working directory) never descends into and leaks secret values.
const SKIP_DIRS: &[&str] = &["target", ".git", "node_modules", ".venv", "__pycache__", "secrets"];
const MAX_FILE_BYTES: u64 = 200_000;
const MAX_OUTPUT_BYTES: usize = 60_000;
const MAX_LINE_BYTES: usize = 500;
// ── files_only mode ───────────────────────────────────────────────────────────
fn collect_matching_files(
path: &std::path::Path,
re: &Regex,
glob: &Option<glob::Pattern>,
max: usize,
out: &mut Vec<String>,
) -> Result<()> {
if out.len() >= max { return Ok(()); }
if path.is_dir() {
let mut entries: Vec<_> = std::fs::read_dir(path)?.filter_map(|e| e.ok()).collect();
entries.sort_by_key(|e| e.file_name());
for entry in entries {
if out.len() >= max { break; }
let p = entry.path();
if p.is_dir() {
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
if SKIP_DIRS.contains(&name) { continue; }
collect_matching_files(&p, re, glob, max, out)?;
} else if file_has_match(&p, re, glob)? {
out.push(p.to_string_lossy().into_owned());
}
}
} else if file_has_match(path, re, glob)? {
out.push(path.to_string_lossy().into_owned());
}
Ok(())
}
fn file_has_match(path: &std::path::Path, re: &Regex, glob: &Option<glob::Pattern>) -> Result<bool> {
if let Some(pat) = glob {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !pat.matches(name) { return Ok(false); }
}
if let Ok(meta) = path.metadata() {
if meta.len() > MAX_FILE_BYTES { return Ok(false); }
}
let text = match std::fs::read(path) { Ok(b) => b, Err(_) => return Ok(false) };
if text.iter().take(8000).any(|&b| b == 0) { return Ok(false); }
let content = match std::str::from_utf8(&text) { Ok(s) => s, Err(_) => return Ok(false) };
Ok(content.lines().any(|l| re.is_match(l)))
}
// ── count mode ────────────────────────────────────────────────────────────────
fn collect_match_counts(
path: &std::path::Path,
re: &Regex,
glob: &Option<glob::Pattern>,
max: usize,
out: &mut Vec<(String, usize)>,
) -> Result<()> {
if out.len() >= max { return Ok(()); }
if path.is_dir() {
let mut entries: Vec<_> = std::fs::read_dir(path)?.filter_map(|e| e.ok()).collect();
entries.sort_by_key(|e| e.file_name());
for entry in entries {
if out.len() >= max { break; }
let p = entry.path();
if p.is_dir() {
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
if SKIP_DIRS.contains(&name) { continue; }
collect_match_counts(&p, re, glob, max, out)?;
} else if let Some(n) = count_file_matches(&p, re, glob)? {
if n > 0 { out.push((p.to_string_lossy().into_owned(), n)); }
}
}
} else if let Some(n) = count_file_matches(path, re, glob)? {
if n > 0 { out.push((path.to_string_lossy().into_owned(), n)); }
}
Ok(())
}
fn count_file_matches(path: &std::path::Path, re: &Regex, glob: &Option<glob::Pattern>) -> Result<Option<usize>> {
if let Some(pat) = glob {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !pat.matches(name) { return Ok(None); }
}
if let Ok(meta) = path.metadata() {
if meta.len() > MAX_FILE_BYTES { return Ok(None); }
}
let text = match std::fs::read(path) { Ok(b) => b, Err(_) => return Ok(None) };
if text.iter().take(8000).any(|&b| b == 0) { return Ok(None); }
let content = match std::str::from_utf8(&text) { Ok(s) => s, Err(_) => return Ok(None) };
Ok(Some(content.lines().filter(|l| re.is_match(l)).count()))
}
// ── content mode ──────────────────────────────────────────────────────────────
fn search_path(
path: &std::path::Path,
re: &Regex,
glob: &Option<glob::Pattern>,
max_results: usize,
context_lines: usize,
matches: &mut Vec<String>,
output_bytes: &mut usize,
truncated: &mut bool,
) -> Result<()> {
if matches.len() >= max_results || *truncated { return Ok(()); }
if path.is_dir() {
let mut entries: Vec<_> = std::fs::read_dir(path)?.filter_map(|e| e.ok()).collect();
entries.sort_by_key(|e| e.file_name());
for entry in entries {
if matches.len() >= max_results || *truncated { break; }
let p = entry.path();
if p.is_dir() {
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
if SKIP_DIRS.contains(&name) { continue; }
search_path(&p, re, glob, max_results, context_lines, matches, output_bytes, truncated)?;
} else {
grep_file(&p, re, glob, max_results, context_lines, matches, output_bytes, truncated)?;
}
}
} else {
grep_file(path, re, glob, max_results, context_lines, matches, output_bytes, truncated)?;
}
Ok(())
}
fn grep_file(
path: &std::path::Path,
re: &Regex,
glob: &Option<glob::Pattern>,
max_results: usize,
context_lines: usize,
matches: &mut Vec<String>,
output_bytes: &mut usize,
truncated: &mut bool,
) -> Result<()> {
if let Some(pat) = glob {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !pat.matches(name) { return Ok(()); }
}
if let Ok(meta) = path.metadata() {
if meta.len() > MAX_FILE_BYTES { return Ok(()); }
}
let text = match std::fs::read(path) { Ok(b) => b, Err(_) => return Ok(()) };
if text.iter().take(8000).any(|&b| b == 0) { return Ok(()); }
let content = match std::str::from_utf8(&text) { Ok(s) => s, Err(_) => return Ok(()) };
let display = path.to_string_lossy();
let lines: Vec<&str> = content.lines().collect();
if context_lines == 0 {
for (i, line) in lines.iter().enumerate() {
if matches.len() >= max_results || *truncated { break; }
if re.is_match(line) {
let snippet = if line.len() > MAX_LINE_BYTES { format!("{}", &line[..MAX_LINE_BYTES]) } else { line.to_string() };
let entry = format!("{}:{}: {}", display, i + 1, snippet);
*output_bytes += entry.len();
if *output_bytes > MAX_OUTPUT_BYTES { *truncated = true; break; }
matches.push(entry);
}
}
} else {
let match_indices: Vec<usize> = lines.iter().enumerate()
.filter(|(_, l)| re.is_match(l))
.map(|(i, _)| i)
.collect();
if match_indices.is_empty() { return Ok(()); }
let mut windows: Vec<(usize, usize)> = Vec::new();
for &m in &match_indices {
let start = m.saturating_sub(context_lines);
let end = (m + context_lines).min(lines.len().saturating_sub(1));
if let Some(last) = windows.last_mut() {
if start <= last.1 + 1 { last.1 = last.1.max(end); continue; }
}
windows.push((start, end));
}
let match_set: std::collections::HashSet<usize> = match_indices.into_iter().collect();
for (wi, (start, end)) in windows.iter().enumerate() {
if matches.len() >= max_results || *truncated { break; }
if wi > 0 {
let sep = format!("{}:---", display);
*output_bytes += sep.len();
matches.push(sep);
}
for idx in *start..=*end {
if matches.len() >= max_results || *truncated { break; }
let marker = if match_set.contains(&idx) { ">" } else { " " };
let line = lines[idx];
let snippet = if line.len() > MAX_LINE_BYTES { format!("{}", &line[..MAX_LINE_BYTES]) } else { line.to_string() };
let entry = format!("{}{}: {}: {}", marker, display, idx + 1, snippet);
*output_bytes += entry.len();
if *output_bytes > MAX_OUTPUT_BYTES { *truncated = true; break; }
matches.push(entry);
}
}
}
Ok(())
}
@@ -0,0 +1,84 @@
use anyhow::Result;
use serde_json::{Value, json};
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL};
use super::{read_to_string, write_string};
pub struct InsertAtLine;
impl InsertAtLine {
pub fn new() -> Self { Self }
}
impl Tool for InsertAtLine {
fn name(&self) -> &str { "insert_at_line" }
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
fn description(&self) -> &str {
"Insert new text immediately before or after a specific line number in a file. \
Relative paths are resolved from the project root; absolute paths (starting with /) are used as-is."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "File path. Relative to project root, or absolute." },
"line": { "type": "integer", "minimum": 1, "description": "1-based line number." },
"content": { "type": "string", "description": "Text to insert. May span multiple lines." },
"placement": {
"type": "string",
"enum": ["before", "after"],
"description": "Whether to insert before or after the target line. Default: \"after\"."
}
},
"required": ["path", "line", "content"]
})
}
fn target_path(&self, args: &Value) -> Option<String> {
super::path_arg(args)
}
fn describe(&self, args: &Value, length: ToolDescriptionLength) -> String {
let path = args["path"].as_str().unwrap_or("?");
match length {
ToolDescriptionLength::Short => {
truncate_label(&format!("insert_at_line `{path}`"), MAX_LABEL_SHORT)
}
ToolDescriptionLength::Full => {
let line = args["line"].as_u64().map(|n| format!(" line {n}")).unwrap_or_default();
truncate_label(&format!("insert_at_line `{path}`{line}"), MAX_LABEL_FULL)
}
}
}
fn execute(&self, args: Value) -> Result<String> {
let user_path = args["path"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
let line_num = args["line"].as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: line"))? as usize;
let new_text = args["content"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: content"))?;
let placement = args["placement"].as_str().unwrap_or("after");
anyhow::ensure!(line_num >= 1, "line must be >= 1");
let text = read_to_string(user_path)?;
let mut lines: Vec<&str> = text.split('\n').collect();
let idx = (line_num - 1).min(lines.len().saturating_sub(1));
let insert_idx = if placement == "before" { idx } else { idx + 1 };
let new_lines: Vec<&str> = new_text.split('\n').collect();
for (i, l) in new_lines.iter().enumerate() {
lines.insert(insert_idx + i, l);
}
let updated = lines.join("\n");
write_string(user_path, &updated)?;
Ok(format!(
"Inserted {} line(s) {} line {} in {user_path}.",
new_lines.len(), placement, line_num
))
}
}
@@ -0,0 +1,100 @@
use std::path::Path;
use anyhow::Result;
use serde_json::{Value, json};
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT};
use super::resolve;
/// Directories to skip unconditionally when walking.
/// `secrets` is skipped so a recursive listing rooted at a parent (e.g. the auto-read
/// working directory) never reveals the contents of the secrets store.
const SKIP_DIRS: &[&str] = &[".git", "target", "node_modules", ".cache", "secrets"];
pub struct ListFiles;
impl ListFiles {
pub fn new() -> Self { Self }
}
impl Tool for ListFiles {
fn name(&self) -> &str { "list_files" }
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
fn description(&self) -> &str {
"List files and directories under a path. \
Use instead of ls/find in the terminal. \
Relative paths are resolved from the project root; absolute paths (starting with /) are used as-is. \
Skips .git, target, node_modules, .cache. \
Returns a JSON array of paths relative to the requested directory. \
Use depth=1 for immediate contents only, depth=2-3 for moderate exploration."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Directory to list. Defaults to project root if omitted."
},
"depth": {
"type": "integer",
"description": "Maximum recursion depth (default 3). Use 1 for immediate contents only."
},
"dirs_only": {
"type": "boolean",
"description": "If true, return only directories and omit files (default false)."
}
}
})
}
fn describe(&self, args: &Value, length: ToolDescriptionLength) -> String {
let path = args["path"].as_str().unwrap_or(".");
let _ = length;
truncate_label(&format!("list_files `{path}`"), MAX_LABEL_SHORT)
}
fn execute(&self, args: Value) -> Result<String> {
let user_path = args["path"].as_str().unwrap_or(".");
let max_depth = args["depth"].as_u64().unwrap_or(3) as usize;
let dirs_only = args["dirs_only"].as_bool().unwrap_or(false);
let dir = resolve(user_path)?;
let mut paths: Vec<String> = Vec::new();
walk(&dir, &dir, 0, max_depth, dirs_only, &mut paths)?;
paths.sort();
Ok(serde_json::to_string(&paths)?)
}
}
fn walk(root: &Path, dir: &Path, depth: usize, max_depth: usize, dirs_only: bool, out: &mut Vec<String>) -> Result<()> {
if !dir.exists() {
return Ok(());
}
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if path.is_dir() {
if SKIP_DIRS.contains(&name) { continue; }
if dirs_only {
let rel = path.strip_prefix(root)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| path.to_string_lossy().to_string());
out.push(rel);
}
if depth + 1 < max_depth {
walk(root, &path, depth + 1, max_depth, dirs_only, out)?;
}
} else if path.is_file() && !dirs_only {
let rel = path.strip_prefix(root)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| path.to_string_lossy().to_string());
out.push(rel);
}
}
Ok(())
}
+139
View File
@@ -0,0 +1,139 @@
mod edit_file;
mod grep_files;
mod insert_at_line;
mod list_files;
mod read_file;
mod replace_lines;
mod search_file;
mod write_file;
use std::path::{Component, Path, PathBuf};
use anyhow::{Context, Result};
use serde_json::Value;
use crate::tools::ToolRegistry;
/// Extracts the `path` argument as an owned string, if present. Single-file
/// tools use this to advertise their target to the UI via `Tool::target_path`,
/// keeping the argument name in one place.
pub(crate) fn path_arg(args: &Value) -> Option<String> {
args.get("path").and_then(Value::as_str).map(str::to_string)
}
pub use edit_file::EditFile;
pub use grep_files::GrepFiles;
pub use insert_at_line::InsertAtLine;
pub use list_files::ListFiles;
pub use read_file::ReadFile;
pub use replace_lines::ReplaceLines;
pub use search_file::SearchFile;
pub use write_file::WriteFile;
/// Resolve a user-supplied path:
/// - starts with `/` → absolute path, used as-is
/// - otherwise → relative to the process working directory (project root)
pub fn resolve(user_path: &str) -> Result<PathBuf> {
let p = PathBuf::from(user_path);
if p.is_absolute() {
Ok(p)
} else {
let cwd = std::env::current_dir()
.context("Failed to read current working directory")?;
Ok(cwd.join(p))
}
}
/// Resolves `path` (relative entries against `base`) to an absolute, canonical form
/// suitable for security prefix-matching. `.`/`..` are resolved and symlinks in the
/// existing portion of the path are followed: the longest existing ancestor is
/// canonicalized via the OS, and any not-yet-existing tail (e.g. a write target that
/// does not exist yet) is appended lexically. Falls back to a pure lexical normalization
/// when nothing along the path can be canonicalized.
///
/// This closes `docs/../secrets/x` traversal and symlink escapes for both the allow
/// fast-paths (`RunContext`) and the deny rules (`approval::normalize_path`).
pub fn canonicalize_for_policy(path: &str, base: &Path) -> PathBuf {
let raw = {
let p = Path::new(path);
if p.is_absolute() { p.to_path_buf() } else { base.join(p) }
};
let cleaned = lexical_normalize(&raw);
// Longest existing ancestor first (ancestors() yields self, then parents).
for ancestor in cleaned.ancestors() {
if let Ok(canon) = std::fs::canonicalize(ancestor) {
// `canon.join("")` appends a trailing separator, so skip the join
// when the tail is empty (the common case: the file itself is its
// first canonicalizable ancestor). Otherwise the canonical path
// ends in '/', leaking into display strings and /api/file requests.
return match cleaned.strip_prefix(ancestor) {
Ok(tail) if !tail.as_os_str().is_empty() => canon.join(tail),
_ => canon,
};
}
}
cleaned
}
/// Pure lexical normalization: resolves `.` and `..` components without touching the
/// filesystem. Used as the base for `canonicalize_for_policy` and as its fallback.
fn lexical_normalize(p: &Path) -> PathBuf {
let mut out = PathBuf::new();
for comp in p.components() {
match comp {
Component::ParentDir => { out.pop(); }
Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
out
}
/// True if `child` is `base` itself or lies inside it. Both should already be canonical
/// (e.g. produced by `canonicalize_for_policy`). Comparison is component-wise, so
/// `/a/bc` is not considered to be under `/a/b`.
pub fn path_under(child: &Path, base: &Path) -> bool {
child.starts_with(base)
}
/// Normalize a user path for display in the UI: relative to the project root when the
/// file lives inside it, absolute otherwise. Resolves `.`/`..` and symlinks via
/// `canonicalize_for_policy` so the same file always yields the same string — keeping
/// the file viewer's "already loaded" check and its watcher subscription consistent.
pub fn relativize_for_display(user_path: &str) -> String {
let cwd = std::env::current_dir().unwrap_or_default();
let abs = canonicalize_for_policy(user_path, &cwd);
let cwd_canon = std::fs::canonicalize(&cwd).unwrap_or(cwd);
match abs.strip_prefix(&cwd_canon) {
Ok(rel) => rel.to_string_lossy().into_owned(),
Err(_) => abs.to_string_lossy().into_owned(),
}
}
pub(super) fn read_to_string(user_path: &str) -> Result<String> {
let abs = resolve(user_path)?;
std::fs::read_to_string(&abs)
.with_context(|| format!("Cannot read file: {user_path}"))
}
pub(super) fn write_string(user_path: &str, content: &str) -> Result<()> {
let abs = resolve(user_path)?;
if let Some(parent) = abs.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
std::fs::write(&abs, content)
.with_context(|| format!("Failed to write: {}", abs.display()))
}
pub fn register_all(registry: &mut ToolRegistry) {
registry.register(EditFile::new());
registry.register(GrepFiles::new());
registry.register(InsertAtLine::new());
registry.register(ListFiles::new());
registry.register(ReadFile::new());
registry.register(ReplaceLines::new());
registry.register(SearchFile::new());
registry.register(WriteFile::new());
}
+106
View File
@@ -0,0 +1,106 @@
use anyhow::Result;
use serde_json::{Value, json};
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL};
use super::read_to_string;
pub struct ReadFile;
impl ReadFile {
pub fn new() -> Self { Self }
}
impl Tool for ReadFile {
fn name(&self) -> &str { "read_file" }
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
fn description(&self) -> &str {
"Read the content of a file with 1-based line numbers. \
Use instead of cat/head/tail in the terminal. \
Returns text prefixed as ' N | line'. When calling edit_file, copy the text after '| ' exactly. \
For large files use start_line/end_line to read in chunks — files over ~2000 lines should never be read whole. \
Use limit to cap output when end_line is unknown."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File path. Relative to project root, or absolute (e.g. /etc/hosts)."
},
"start_line": {
"type": "integer",
"description": "First line to read (1-based, inclusive). Omit to start from the beginning."
},
"end_line": {
"type": "integer",
"description": "Last line to read (1-based, inclusive). Omit to read to the end of the file."
},
"limit": {
"type": "integer",
"description": "Maximum number of lines to return (max 2000). Applied after start_line when end_line is omitted.",
"maximum": 2000
}
},
"required": ["path"]
})
}
fn target_path(&self, args: &Value) -> Option<String> {
super::path_arg(args)
}
fn describe(&self, args: &Value, length: ToolDescriptionLength) -> String {
let path = args["path"].as_str().unwrap_or("?");
match length {
ToolDescriptionLength::Short => {
truncate_label(&format!("read_file `{path}`"), MAX_LABEL_SHORT)
}
ToolDescriptionLength::Full => {
let range = match (args["start_line"].as_u64(), args["end_line"].as_u64()) {
(Some(s), Some(e)) => format!(" lines {s}-{e}"),
(Some(s), None) => format!(" from line {s}"),
(None, Some(e)) => format!(" to line {e}"),
_ => String::new(),
};
truncate_label(&format!("read_file `{path}`{range}"), MAX_LABEL_FULL)
}
}
}
fn execute(&self, args: Value) -> Result<String> {
let user_path = args["path"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
let content = read_to_string(user_path)?;
let lines: Vec<&str> = content.lines().collect();
let total = lines.len();
let limit = args["limit"].as_u64().map(|n| n.min(2000) as usize);
let start = args["start_line"].as_u64()
.map(|n| (n as usize).saturating_sub(1))
.unwrap_or(0);
let end = match (args["end_line"].as_u64(), limit) {
(Some(e), _) => (e as usize).min(total),
(None, Some(l)) => (start + l).min(total),
(None, None) => total,
};
if start >= total && total > 0 {
return Ok(format!("(file has only {total} lines; start_line {start_line} is out of range)",
start_line = start + 1));
}
let end = end.max(start);
let width = total.to_string().len().max(3);
let numbered = lines[start..end]
.iter()
.enumerate()
.map(|(i, line)| format!("{:>width$} | {line}", start + i + 1))
.collect::<Vec<_>>()
.join("\n");
Ok(numbered)
}
}
@@ -0,0 +1,88 @@
use anyhow::Result;
use serde_json::{Value, json};
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL};
use super::{read_to_string, write_string};
pub struct ReplaceLines;
impl ReplaceLines {
pub fn new() -> Self { Self }
}
impl Tool for ReplaceLines {
fn name(&self) -> &str { "replace_lines" }
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
fn description(&self) -> &str {
"Replace a range of lines in a file with new text. \
Relative paths are resolved from the project root; absolute paths (starting with /) are used as-is. \
Use the 1-based line numbers shown by read_file. `from_line` and `to_line` are inclusive."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "File path. Relative to project root, or absolute." },
"from_line": { "type": "integer", "description": "First line to replace (1-based, inclusive)." },
"to_line": { "type": "integer", "description": "Last line to replace (1-based, inclusive)." },
"new": { "type": "string", "description": "Replacement text." }
},
"required": ["path", "from_line", "to_line", "new"]
})
}
fn target_path(&self, args: &Value) -> Option<String> {
super::path_arg(args)
}
fn describe(&self, args: &Value, length: ToolDescriptionLength) -> String {
let path = args["path"].as_str().unwrap_or("?");
match length {
ToolDescriptionLength::Short => {
truncate_label(&format!("replace_lines `{path}`"), MAX_LABEL_SHORT)
}
ToolDescriptionLength::Full => {
let from = args["from_line"].as_u64().map(|n| n.to_string()).unwrap_or_else(|| "?".into());
let to = args["to_line"].as_u64().map(|n| n.to_string()).unwrap_or_else(|| "?".into());
truncate_label(&format!("replace_lines `{path}` lines {from}-{to}"), MAX_LABEL_FULL)
}
}
}
fn execute(&self, args: Value) -> Result<String> {
let user_path = args["path"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
let from_line = args["from_line"].as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: from_line"))? as usize;
let to_line = args["to_line"].as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: to_line"))? as usize;
let new = args["new"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: new"))?;
if from_line == 0 { anyhow::bail!("from_line must be >= 1"); }
if to_line < from_line { anyhow::bail!("to_line must be >= from_line"); }
let content = read_to_string(user_path)?;
let mut lines: Vec<&str> = content.lines().collect();
let total = lines.len();
if from_line > total {
anyhow::bail!("from_line {from_line} exceeds file length ({total} lines)");
}
let to_clamped = to_line.min(total);
let new_lines: Vec<&str> = new.lines().collect();
lines.splice((from_line - 1)..to_clamped, new_lines);
let has_trailing = content.ends_with('\n');
let mut updated = lines.join("\n");
if has_trailing { updated.push('\n'); }
write_string(user_path, &updated)?;
Ok(format!(
"Replaced lines {from_line}{to_clamped} in {user_path} with {} new lines.",
new.lines().count()
))
}
}
@@ -0,0 +1,100 @@
use anyhow::Result;
use serde_json::{Value, json};
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL};
use super::read_to_string;
pub struct SearchFile;
impl SearchFile {
pub fn new() -> Self { Self }
}
impl Tool for SearchFile {
fn name(&self) -> &str { "search_file" }
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
fn description(&self) -> &str {
"Search for lines containing a substring in a file. \
Relative paths are resolved from the project root; absolute paths (starting with /) are used as-is. \
Returns each matching line with context, prefixed with 1-based line numbers in ' N | ' format."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "File path. Relative to project root, or absolute." },
"query": { "type": "string", "description": "Substring to search for (case-insensitive)." },
"context_lines": {
"type": "integer",
"description": "Lines of context above and below each match (default 3, max 10).",
"default": 3
}
},
"required": ["path", "query"]
})
}
fn target_path(&self, args: &Value) -> Option<String> {
super::path_arg(args)
}
fn describe(&self, args: &Value, length: ToolDescriptionLength) -> String {
let path = args["path"].as_str().unwrap_or("?");
match length {
ToolDescriptionLength::Short => {
truncate_label(&format!("search_file `{path}`"), MAX_LABEL_SHORT)
}
ToolDescriptionLength::Full => {
let query = args["query"].as_str().unwrap_or("?");
truncate_label(&format!("search_file `{path}` for \"{query}\""), MAX_LABEL_FULL)
}
}
}
fn execute(&self, args: Value) -> Result<String> {
let user_path = args["path"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
let query = args["query"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: query"))?;
let context = args["context_lines"].as_u64().unwrap_or(3).min(10) as usize;
let text = read_to_string(user_path)?;
let lines: Vec<&str> = text.lines().collect();
let lower_query = query.to_lowercase();
let width = lines.len().to_string().len().max(3);
let matches: Vec<usize> = lines.iter().enumerate()
.filter(|(_, l)| l.to_lowercase().contains(&lower_query))
.map(|(i, _)| i)
.collect();
if matches.is_empty() {
return Ok(format!("No matches found for {:?} in {user_path}.", query));
}
let mut chunks: Vec<(usize, usize)> = Vec::new();
for &m in &matches {
let start = m.saturating_sub(context);
let end = (m + context).min(lines.len() - 1);
if let Some(last) = chunks.last_mut() {
if start <= last.1 + 1 { last.1 = last.1.max(end); continue; }
}
chunks.push((start, end));
}
let match_set: std::collections::HashSet<usize> = matches.into_iter().collect();
let mut out = format!("{} match(es) in {user_path}:\n", match_set.len());
for (ci, (start, end)) in chunks.iter().enumerate() {
if ci > 0 { out.push_str(" ···\n"); }
for idx in *start..=*end {
let marker = if match_set.contains(&idx) { ">" } else { " " };
out.push_str(&format!("{marker}{:>width$} | {}\n", idx + 1, lines[idx]));
}
}
Ok(out)
}
}
@@ -0,0 +1,67 @@
use anyhow::Result;
use serde_json::{Value, json};
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT};
use super::{resolve, write_string};
pub struct WriteFile;
impl WriteFile {
pub fn new() -> Self { Self }
}
impl Tool for WriteFile {
fn name(&self) -> &str { "write_file" }
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
fn description(&self) -> &str {
"Create a new file or fully overwrite an existing one. \
Use instead of echo/cat heredoc in the terminal. \
Relative paths are resolved from the project root; absolute paths (starting with /) are used as-is. \
OVERWRITES the entire file — for targeted edits to an existing file use edit_file instead."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File path. Relative to project root, or absolute."
},
"content": {
"type": "string",
"description": "Full content to write to the file."
}
},
"required": ["path", "content"]
})
}
fn target_path(&self, args: &Value) -> Option<String> {
super::path_arg(args)
}
fn describe(&self, args: &Value, length: ToolDescriptionLength) -> String {
let path = args["path"].as_str().unwrap_or("?");
let _ = length;
truncate_label(&format!("write_file `{path}`"), MAX_LABEL_SHORT)
}
fn execute(&self, args: Value) -> Result<String> {
let user_path = args["path"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
let content = args["content"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing required argument: content"))?;
let abs = resolve(user_path)?;
let existed = abs.exists();
write_string(user_path, content)?;
if existed {
Ok(format!("Overwrote {user_path} ({} bytes).", content.len()))
} else {
Ok(format!("Created {user_path} ({} bytes).", content.len()))
}
}
}