From 8dca417cd31ea7737883b1689372277796b53caa Mon Sep 17 00:00:00 2001 From: Owen Qwen Date: Sun, 21 Jun 2026 11:02:01 -0500 Subject: [PATCH] Add bundled read-only docs --- Cargo.lock | 20 ++++++++ Cargo.toml | 1 + README.md | 6 ++- build.rs | 77 ++++++++++++++++++++++++++++ docs/README.md | 5 ++ src/agent.rs | 5 +- src/config.rs | 12 ++++- src/docs.rs | 102 +++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/prompt.rs | 9 +++- src/tools/edit.rs | 5 +- src/tools/grep.rs | 4 +- src/tools/ls.rs | 4 +- src/tools/mod.rs | 3 +- src/tools/path.rs | 107 ++++++++++++++++++++++++++++++++++++--- src/tools/read.rs | 5 +- src/tools/write.rs | 5 +- tests/docs_tests.rs | 20 ++++++++ tests/tool_tests.rs | 120 +++++++++++++++++++++++++++++++++++++++++++- 19 files changed, 484 insertions(+), 27 deletions(-) create mode 100644 build.rs create mode 100644 docs/README.md create mode 100644 src/docs.rs create mode 100644 tests/docs_tests.rs diff --git a/Cargo.lock b/Cargo.lock index d9bb882..71f03bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -161,6 +161,7 @@ dependencies = [ "dirs", "futures-util", "ignore", + "include_dir", "nanoid", "ratatui", "regex", @@ -923,6 +924,25 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indexmap" version = "2.14.0" diff --git a/Cargo.toml b/Cargo.toml index dc7df8c..c961f3e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ crossterm = "0.28" dirs = "5" futures-util = "0.3" ignore = "0.4" +include_dir = "0.7" nanoid = "0.4" ratatui = "0.28" regex = "1" diff --git a/README.md b/README.md index df73cd3..56fd0c9 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ Optional config lives at `~/.cass/config.json`: Extra global instructions can be placed in `~/.cass/global.md`. +Bundled documentation from this build is embedded into the binary and installed to `~/.cass/docs` on startup. + ## Usage ```sh @@ -79,8 +81,8 @@ Resume this chat with: cass --resume ## Tools -Read-only mode allows `ls`, `read`, and `grep` within the launch cwd/`--cwd`. +Read-only mode allows `ls`, `read`, and `grep` within the launch cwd/`--cwd` and the bundled docs directory at `~/.cass/docs`. -Full-access mode additionally allows `write` and `edit`. Mutating tools use atomic writes where practical: Cass writes to a temporary file first, then renames it into place after validation/write success. +Full-access mode additionally allows `write` and `edit`. Mutating tools use atomic writes where practical: Cass writes to a temporary file first, then renames it into place after validation/write success. `write` and `edit` are always blocked under `~/.cass/docs`. There is no shell/bash tool in v1. diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..699a3c5 --- /dev/null +++ b/build.rs @@ -0,0 +1,77 @@ +use std::env; +use std::fs; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; + +const FNV_OFFSET: u64 = 14_695_981_039_346_656_037; +const FNV_PRIME: u64 = 1_099_511_628_211; + +fn main() { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); + let docs_dir = manifest_dir.join("docs"); + + println!("cargo:rerun-if-changed={}", docs_dir.display()); + + let mut hash = Fnv64::new(); + if docs_dir.exists() { + if let Err(err) = hash_dir(&docs_dir, &docs_dir, &mut hash) { + panic!( + "failed to hash docs directory {}: {err}", + docs_dir.display() + ); + } + } + + println!("cargo:rustc-env=CASS_DOCS_HASH={:016x}", hash.finish()); +} + +fn hash_dir(base: &Path, dir: &Path, hash: &mut Fnv64) -> io::Result<()> { + let mut entries = fs::read_dir(dir)?.collect::>>()?; + entries.sort_by_key(|entry| entry.path()); + + for entry in entries { + let path = entry.path(); + println!("cargo:rerun-if-changed={}", path.display()); + + let rel = path + .strip_prefix(base) + .expect("entry is below docs base") + .to_string_lossy() + .replace('\\', "/"); + hash.write(rel.as_bytes()); + hash.write(&[0]); + + let file_type = entry.file_type()?; + if file_type.is_dir() { + hash.write(&[1]); + hash_dir(base, &path, hash)?; + } else if file_type.is_file() { + hash.write(&[2]); + let mut file = fs::File::open(&path)?; + let mut buf = Vec::new(); + file.read_to_end(&mut buf)?; + hash.write(&buf); + } + } + + Ok(()) +} + +struct Fnv64(u64); + +impl Fnv64 { + fn new() -> Self { + Self(FNV_OFFSET) + } + + fn write(&mut self, bytes: &[u8]) { + for byte in bytes { + self.0 ^= u64::from(*byte); + self.0 = self.0.wrapping_mul(FNV_PRIME); + } + } + + fn finish(self) -> u64 { + self.0 + } +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..e77d297 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,5 @@ +# Cass bundled docs + +These docs are embedded into the `cass` binary at build time and installed to `~/.cass/docs` when Cass starts. + +Cass tools may list, search, and read this directory. Mutating tools are blocked from writing here, even in full-access mode. diff --git a/src/agent.rs b/src/agent.rs index acb00f8..f2e1f40 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -59,10 +59,12 @@ pub async fn run_turn( } }; + let docs_dir = settings.config.docs_dir(); let tool_ctx = ToolContext { mode: settings.mode, cwd: settings.cwd.clone(), - read_only_root: settings.cwd.clone(), + read_roots: vec![settings.cwd.clone(), docs_dir.clone()], + blocked_write_roots: vec![docs_dir.clone()], model_result_limit: settings.config.model_tool_result_limit, }; @@ -72,6 +74,7 @@ pub async fn run_turn( &conversation.base_system_prompt(), settings.mode, &settings.cwd, + &docs_dir, &settings.config.model, &allowed, ); diff --git a/src/config.rs b/src/config.rs index 263032b..2b5212c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -28,10 +28,13 @@ pub struct Config { pub model_tool_result_limit: usize, pub ui_tool_result_limit: usize, pub root: PathBuf, + pub docs_dir: PathBuf, } impl Default for Config { fn default() -> Self { + let root = cass_root(); + let docs_dir = root.join("docs"); Self { provider: "openai-compatible".to_string(), model: "accounts/fireworks/models/qwen3p7-plus".to_string(), @@ -41,7 +44,8 @@ impl Default for Config { context_message_limit: 80, model_tool_result_limit: 24_000, ui_tool_result_limit: 4_000, - root: cass_root(), + root, + docs_dir, } } } @@ -57,8 +61,10 @@ impl Config { let root = cass_root(); fs::create_dir_all(root.join("conversations")) .with_context(|| format!("creating {}", root.join("conversations").display()))?; + let docs_dir = crate::docs::install(&root)?; let mut cfg = Config::default(); cfg.root = root.clone(); + cfg.docs_dir = docs_dir; let path = root.join("config.json"); if path.exists() { @@ -118,4 +124,8 @@ impl Config { pub fn global_path(&self) -> PathBuf { self.root.join("global.md") } + + pub fn docs_dir(&self) -> PathBuf { + self.docs_dir.clone() + } } diff --git a/src/docs.rs b/src/docs.rs new file mode 100644 index 0000000..ca77a51 --- /dev/null +++ b/src/docs.rs @@ -0,0 +1,102 @@ +use anyhow::{bail, Context, Result}; +use include_dir::{include_dir, Dir}; +use std::fs; +use std::path::{Path, PathBuf}; + +static BUNDLED_DOCS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/docs"); +const DOCS_HASH: &str = env!("CASS_DOCS_HASH"); +const STAMP_FILE: &str = ".cass-docs-hash"; + +pub fn install(cass_root: &Path) -> Result { + fs::create_dir_all(cass_root) + .with_context(|| format!("creating Cass root {}", cass_root.display()))?; + + let dest = cass_root.join("docs"); + let stamp = dest.join(STAMP_FILE); + let dest_is_managed_dir = match fs::symlink_metadata(&dest) { + Ok(meta) => meta.is_dir(), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => false, + Err(err) => bail!("checking {}: {err}", dest.display()), + }; + if dest_is_managed_dir + && fs::read_to_string(&stamp) + .map(|text| text.trim() == DOCS_HASH) + .unwrap_or(false) + { + return Ok(dest); + } + + let tmp = cass_root.join(format!( + ".docs.tmp-{}-{}", + std::process::id(), + chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() + )); + let backup = cass_root.join(format!( + ".docs.backup-{}-{}", + std::process::id(), + chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() + )); + + remove_path_if_exists(&tmp)?; + remove_path_if_exists(&backup)?; + + if let Err(err) = extract_docs(&tmp) { + let _ = remove_path_if_exists(&tmp); + return Err(err); + } + + if path_exists(&dest)? { + fs::rename(&dest, &backup).with_context(|| { + format!( + "moving existing docs {} to {}", + dest.display(), + backup.display() + ) + })?; + } + + if let Err(err) = fs::rename(&tmp, &dest) + .with_context(|| format!("installing bundled docs to {}", dest.display())) + { + let _ = remove_path_if_exists(&dest); + if path_exists(&backup).unwrap_or(false) { + let _ = fs::rename(&backup, &dest); + } + return Err(err); + } + + remove_path_if_exists(&backup)?; + Ok(dest) +} + +pub fn docs_hash() -> &'static str { + DOCS_HASH +} + +fn extract_docs(tmp: &Path) -> Result<()> { + fs::create_dir_all(tmp).with_context(|| format!("creating docs temp dir {}", tmp.display()))?; + BUNDLED_DOCS + .extract(tmp) + .with_context(|| format!("extracting bundled docs to {}", tmp.display()))?; + fs::write(tmp.join(STAMP_FILE), format!("{DOCS_HASH}\n")) + .with_context(|| format!("writing docs stamp in {}", tmp.display()))?; + Ok(()) +} + +fn path_exists(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(err) => bail!("checking {}: {err}", path.display()), + } +} + +fn remove_path_if_exists(path: &Path) -> Result<()> { + match fs::symlink_metadata(path) { + Ok(meta) if meta.is_dir() => fs::remove_dir_all(path) + .with_context(|| format!("removing directory {}", path.display())), + Ok(_) => fs::remove_file(path).with_context(|| format!("removing file {}", path.display())), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => bail!("checking {}: {err}", path.display()), + } +} diff --git a/src/lib.rs b/src/lib.rs index 96d7033..e068c4e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod app; pub mod cli; pub mod config; pub mod conversation; +pub mod docs; pub mod error; pub mod prompt; pub mod providers; diff --git a/src/prompt.rs b/src/prompt.rs index 24bada8..420c887 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -24,6 +24,7 @@ pub fn build_effective_system_prompt( base: &str, mode: AccessMode, cwd: &Path, + docs_dir: &Path, model: &str, allowed_tools: &[String], ) -> String { @@ -33,13 +34,17 @@ pub fn build_effective_system_prompt( prompt.push_str(&format!("Model: {model}.\n")); prompt.push_str(&format!("Access mode: {}.\n", mode.as_str())); prompt.push_str(&format!("Launch working directory: {}.\n", cwd.display())); + prompt.push_str(&format!( + "Bundled Cass docs directory: {}. This directory is read-only for tools. Use ls, read, and grep there when you need Cass documentation.\n", + docs_dir.display() + )); prompt.push_str(&format!( "Allowed tools this turn: {}.\n\n", allowed_tools.join(", ") )); match mode { - AccessMode::ReadOnly => prompt.push_str("In read-only mode, you may inspect files with ls, read, and grep only inside the launch working directory. Do not request write or edit. If a task requires modification, explain what needs full-access mode.\n\n"), - AccessMode::FullAccess => prompt.push_str("In full-access mode, you may request ls, read, grep, write, and edit when needed. Cass does not restrict paths to the launch directory, but normal operating-system permissions still apply.\n\n"), + AccessMode::ReadOnly => prompt.push_str("In read-only mode, you may inspect files with ls, read, and grep only inside the launch working directory or bundled Cass docs directory. Do not request write or edit. If a task requires modification, explain what needs full-access mode.\n\n"), + AccessMode::FullAccess => prompt.push_str("In full-access mode, you may request ls, read, grep, write, and edit when needed. Cass does not restrict read paths to the launch directory, but normal operating-system permissions still apply. write and edit are still blocked under the bundled Cass docs directory.\n\n"), } prompt.push_str("6. Response behavior\n\nAssistant output is streamed to the user. Keep user-facing text direct and useful. Tool calls and results are visible to the user, so avoid claiming work happened until the relevant tool result confirms it.\n"); prompt diff --git a/src/tools/edit.rs b/src/tools/edit.rs index 6a96acd..4e9cc06 100644 --- a/src/tools/edit.rs +++ b/src/tools/edit.rs @@ -19,7 +19,7 @@ struct EditArg { pub fn spec() -> ToolSpec { ToolSpec { name: "edit".into(), - description: "Safely edit a file using exact old_text/new_text replacements. Every old_text must match exactly once in the original file. Requires full-access mode.".into(), + description: "Safely edit a file using exact old_text/new_text replacements. Every old_text must match exactly once in the original file. Requires full-access mode. Writes under Cass bundled docs are blocked.".into(), parameters: schema::object(json!({ "path": {"type":"string"}, "edits": {"type":"array", "items": { @@ -40,7 +40,8 @@ pub fn run(args: Value, ctx: &ToolContext) -> Result { if args.edits.is_empty() { bail!("edit requires at least one replacement"); } - let path = super::path::resolve_for_write(&args.path, &ctx.cwd, ctx.mode)?; + let path = + super::path::resolve_for_write(&args.path, &ctx.cwd, ctx.mode, &ctx.blocked_write_roots)?; let original = fs::read_to_string(&path)?; let mut ranges: Vec<(Range, &str)> = Vec::new(); for edit in &args.edits { diff --git a/src/tools/grep.rs b/src/tools/grep.rs index 067bd86..2f07e88 100644 --- a/src/tools/grep.rs +++ b/src/tools/grep.rs @@ -31,7 +31,7 @@ fn default_max() -> usize { pub fn spec() -> ToolSpec { ToolSpec { name: "grep".into(), - description: "Search files or directories for literal text or regex matches. Use before read for large inputs.".into(), + description: "Search files or directories for literal text or regex matches. Use before read for large inputs. In read-only mode, paths must stay inside the launch cwd or bundled docs directory.".into(), parameters: schema::object(json!({ "query": {"type":"string"}, "paths": {"type":"array", "items":{"type":"string"}, "default":["."]}, @@ -62,7 +62,7 @@ pub fn run(args: Value, ctx: &ToolContext) -> Result { let mut files = Vec::new(); for p in &args.paths { - let path = super::path::resolve_existing(p, &ctx.cwd, &ctx.read_only_root, ctx.mode)?; + let path = super::path::resolve_existing(p, &ctx.cwd, &ctx.read_roots, ctx.mode)?; collect_files(&path, &mut files)?; } diff --git a/src/tools/ls.rs b/src/tools/ls.rs index e6c16b4..6134b6a 100644 --- a/src/tools/ls.rs +++ b/src/tools/ls.rs @@ -16,7 +16,7 @@ fn default_path() -> String { pub fn spec() -> ToolSpec { ToolSpec { name: "ls".into(), - description: "List a directory. In read-only mode, paths must stay inside the launch cwd." + description: "List a directory. In read-only mode, paths must stay inside the launch cwd or bundled docs directory." .into(), parameters: schema::object( json!({ @@ -29,7 +29,7 @@ pub fn spec() -> ToolSpec { pub fn run(args: Value, ctx: &ToolContext) -> Result { let args: Args = serde_json::from_value(args)?; - let path = super::path::resolve_existing(&args.path, &ctx.cwd, &ctx.read_only_root, ctx.mode)?; + let path = super::path::resolve_existing(&args.path, &ctx.cwd, &ctx.read_roots, ctx.mode)?; if !path.is_dir() { bail!("not a directory: {}", path.display()); } diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 0b4d426..f768947 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -23,7 +23,8 @@ pub struct ToolSpec { pub struct ToolContext { pub mode: AccessMode, pub cwd: PathBuf, - pub read_only_root: PathBuf, + pub read_roots: Vec, + pub blocked_write_roots: Vec, pub model_result_limit: usize, } diff --git a/src/tools/path.rs b/src/tools/path.rs index 02bbf3d..48e1cb1 100644 --- a/src/tools/path.rs +++ b/src/tools/path.rs @@ -1,5 +1,6 @@ use crate::access::AccessMode; use anyhow::{bail, Context, Result}; +use std::ffi::OsString; use std::fs; use std::path::{Component, Path, PathBuf}; @@ -15,27 +16,117 @@ pub fn expand_tilde(path: &str) -> PathBuf { PathBuf::from(path) } -pub fn resolve_existing(input: &str, cwd: &Path, root: &Path, mode: AccessMode) -> Result { +pub fn resolve_existing( + input: &str, + cwd: &Path, + read_roots: &[PathBuf], + mode: AccessMode, +) -> Result { let p = expand_tilde(input); let abs = if p.is_absolute() { p } else { cwd.join(p) }; let canon = fs::canonicalize(&abs).with_context(|| format!("resolving {}", abs.display()))?; if matches!(mode, AccessMode::ReadOnly) { - let root_canon = - fs::canonicalize(root).with_context(|| format!("resolving root {}", root.display()))?; - if !canon.starts_with(&root_canon) { - bail!("path escapes read-only root: {}", abs.display()); - } + ensure_under_any_root(&canon, read_roots)?; } Ok(canon) } -pub fn resolve_for_write(input: &str, cwd: &Path, mode: AccessMode) -> Result { +pub fn resolve_for_write( + input: &str, + cwd: &Path, + mode: AccessMode, + blocked_write_roots: &[PathBuf], +) -> Result { if !mode.can_write() { bail!("write access requires full-access mode"); } let p = expand_tilde(input); let abs = if p.is_absolute() { p } else { cwd.join(p) }; - Ok(normalize_lexical(&abs)) + let normalized = normalize_lexical(&abs); + ensure_not_under_any_root(&normalized, blocked_write_roots)?; + Ok(normalized) +} + +fn ensure_under_any_root(path: &Path, roots: &[PathBuf]) -> Result<()> { + for root in roots { + let root_canon = fs::canonicalize(root) + .with_context(|| format!("resolving read root {}", root.display()))?; + if path.starts_with(&root_canon) { + return Ok(()); + } + } + + let allowed = roots + .iter() + .map(|root| root.display().to_string()) + .collect::>() + .join(", "); + bail!( + "path escapes read-only roots: {} (allowed roots: {})", + path.display(), + if allowed.is_empty() { + "" + } else { + &allowed + } + ) +} + +fn ensure_not_under_any_root(path: &Path, roots: &[PathBuf]) -> Result<()> { + if roots.is_empty() { + return Ok(()); + } + + let lexical = normalize_lexical(path); + for root in roots { + let root_lexical = normalize_lexical(root); + if lexical.starts_with(&root_lexical) { + bail!( + "writes are blocked under read-only docs directory: {}", + root_lexical.display() + ); + } + } + + let canon = canonicalize_for_policy(path)?; + for root in roots { + let root_canon = canonicalize_for_policy(root) + .with_context(|| format!("resolving blocked write root {}", root.display()))?; + if canon.starts_with(&root_canon) { + bail!( + "writes are blocked under read-only docs directory: {}", + root_canon.display() + ); + } + } + Ok(()) +} + +fn canonicalize_for_policy(path: &Path) -> Result { + if path.exists() { + return fs::canonicalize(path).with_context(|| format!("resolving {}", path.display())); + } + + let mut missing = Vec::::new(); + let mut ancestor = path; + loop { + if ancestor.exists() { + let mut out = fs::canonicalize(ancestor) + .with_context(|| format!("resolving {}", ancestor.display()))?; + for component in missing.iter().rev() { + out.push(component); + } + return Ok(normalize_lexical(&out)); + } + + let Some(name) = ancestor.file_name() else { + bail!("no existing ancestor for {}", path.display()); + }; + missing.push(name.to_os_string()); + ancestor = ancestor + .parent() + .ok_or_else(|| anyhow::anyhow!("no existing ancestor for {}", path.display()))?; + } } fn normalize_lexical(path: &Path) -> PathBuf { diff --git a/src/tools/read.rs b/src/tools/read.rs index d952ab1..c2edf34 100644 --- a/src/tools/read.rs +++ b/src/tools/read.rs @@ -18,7 +18,7 @@ struct FileArg { pub fn spec() -> ToolSpec { ToolSpec { name: "read".into(), - description: "Read one or more text files, optionally with 1-indexed line ranges like 35-60, 35-, or -60.".into(), + description: "Read one or more text files, optionally with 1-indexed line ranges like 35-60, 35-, or -60. In read-only mode, paths must stay inside the launch cwd or bundled docs directory.".into(), parameters: schema::object(json!({ "files": { "type":"array", @@ -43,8 +43,7 @@ pub fn run(args: Value, ctx: &ToolContext) -> Result { } let mut out = String::new(); for file in args.files { - let path = - super::path::resolve_existing(&file.path, &ctx.cwd, &ctx.read_only_root, ctx.mode)?; + let path = super::path::resolve_existing(&file.path, &ctx.cwd, &ctx.read_roots, ctx.mode)?; if !path.is_file() { bail!("not a file: {}", path.display()); } diff --git a/src/tools/write.rs b/src/tools/write.rs index 0370b64..fd6e0d8 100644 --- a/src/tools/write.rs +++ b/src/tools/write.rs @@ -15,7 +15,7 @@ struct Args { pub fn spec() -> ToolSpec { ToolSpec { name: "write".into(), - description: "Create or overwrite a text file. Requires full-access mode. Uses atomic temp-file-and-rename where practical.".into(), + description: "Create or overwrite a text file. Requires full-access mode. Writes under Cass bundled docs are blocked. Uses atomic temp-file-and-rename where practical.".into(), parameters: schema::object(json!({ "path": {"type":"string"}, "content": {"type":"string"} @@ -25,7 +25,8 @@ pub fn spec() -> ToolSpec { pub fn run(args: Value, ctx: &ToolContext) -> Result { let args: Args = serde_json::from_value(args)?; - let path = super::path::resolve_for_write(&args.path, &ctx.cwd, ctx.mode)?; + let path = + super::path::resolve_for_write(&args.path, &ctx.cwd, ctx.mode, &ctx.blocked_write_roots)?; atomic_write(&path, args.content.as_bytes())?; Ok(format!( "wrote {} bytes to {}", diff --git a/tests/docs_tests.rs b/tests/docs_tests.rs new file mode 100644 index 0000000..126d3f0 --- /dev/null +++ b/tests/docs_tests.rs @@ -0,0 +1,20 @@ +use tempfile::tempdir; + +#[test] +fn install_extracts_bundled_docs_with_stamp() { + let root = tempdir().unwrap(); + + let docs_dir = cassady::docs::install(root.path()).unwrap(); + + assert_eq!(docs_dir, root.path().join("docs")); + assert!(docs_dir.join("README.md").is_file()); + assert_eq!( + std::fs::read_to_string(docs_dir.join(".cass-docs-hash")) + .unwrap() + .trim(), + cassady::docs::docs_hash() + ); + + let second_install = cassady::docs::install(root.path()).unwrap(); + assert_eq!(second_install, docs_dir); +} diff --git a/tests/tool_tests.rs b/tests/tool_tests.rs index a65ecfc..0ee7bfa 100644 --- a/tests/tool_tests.rs +++ b/tests/tool_tests.rs @@ -7,7 +7,18 @@ fn ctx(root: &std::path::Path, mode: AccessMode) -> ToolContext { ToolContext { mode, cwd: root.to_path_buf(), - read_only_root: root.to_path_buf(), + read_roots: vec![root.to_path_buf()], + blocked_write_roots: Vec::new(), + model_result_limit: 100_000, + } +} + +fn ctx_with_docs(root: &std::path::Path, docs: &std::path::Path, mode: AccessMode) -> ToolContext { + ToolContext { + mode, + cwd: root.to_path_buf(), + read_roots: vec![root.to_path_buf(), docs.to_path_buf()], + blocked_write_roots: vec![docs.to_path_buf()], model_result_limit: 100_000, } } @@ -62,6 +73,43 @@ async fn read_and_grep_work() { assert!(grep.content.contains("a.txt:2")); } +#[tokio::test] +async fn read_only_can_read_and_search_docs_root() { + let dir = tempdir().unwrap(); + let docs = tempdir().unwrap(); + let outside = tempdir().unwrap(); + std::fs::write(docs.path().join("guide.md"), "# Guide\nneedle in docs\n").unwrap(); + std::fs::write(outside.path().join("secret.txt"), "secret").unwrap(); + let context = ctx_with_docs(dir.path(), docs.path(), AccessMode::ReadOnly); + + let read = tools::execute( + "read", + json!({"files":[{"path": docs.path().join("guide.md").display().to_string()}]}), + &context, + ) + .await; + assert!(read.ok); + assert!(read.content.contains("needle in docs")); + + let grep = tools::execute( + "grep", + json!({"query":"needle", "paths":[docs.path().display().to_string()]}), + &context, + ) + .await; + assert!(grep.ok); + assert!(grep.content.contains("guide.md:2")); + + let outside_read = tools::execute( + "read", + json!({"files":[{"path": outside.path().join("secret.txt").display().to_string()}]}), + &context, + ) + .await; + assert!(!outside_read.ok); + assert!(outside_read.content.contains("escapes read-only root")); +} + #[tokio::test] async fn edit_is_exact_and_atomic_on_validation_failure() { let dir = tempdir().unwrap(); @@ -92,3 +140,73 @@ async fn edit_is_exact_and_atomic_on_validation_failure() { "alpha\nBETA\ngamma\n" ); } + +#[tokio::test] +async fn full_access_blocks_write_and_edit_under_docs_root() { + let dir = tempdir().unwrap(); + let docs = tempdir().unwrap(); + std::fs::write(docs.path().join("guide.md"), "original docs\n").unwrap(); + let context = ctx_with_docs(dir.path(), docs.path(), AccessMode::FullAccess); + + let write = tools::execute( + "write", + json!({"path": docs.path().join("new.md").display().to_string(), "content":"nope"}), + &context, + ) + .await; + assert!(!write.ok); + assert!(write.content.contains("writes are blocked")); + assert!(!docs.path().join("new.md").exists()); + + let edit = tools::execute( + "edit", + json!({"path": docs.path().join("guide.md").display().to_string(), "edits":[{"old_text":"original", "new_text":"changed"}]}), + &context, + ) + .await; + assert!(!edit.ok); + assert!(edit.content.contains("writes are blocked")); + assert_eq!( + std::fs::read_to_string(docs.path().join("guide.md")).unwrap(), + "original docs\n" + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn full_access_blocks_writes_through_symlinked_docs_dir() { + let dir = tempdir().unwrap(); + let docs = tempdir().unwrap(); + std::os::unix::fs::symlink(docs.path(), dir.path().join("docs_link")).unwrap(); + let context = ctx_with_docs(dir.path(), docs.path(), AccessMode::FullAccess); + + let write = tools::execute( + "write", + json!({"path":"docs_link/new.md", "content":"nope"}), + &context, + ) + .await; + assert!(!write.ok); + assert!(write.content.contains("writes are blocked")); + assert!(!docs.path().join("new.md").exists()); +} + +#[cfg(unix)] +#[tokio::test] +async fn full_access_blocks_lexical_writes_under_docs_even_when_symlink_points_outside() { + let dir = tempdir().unwrap(); + let docs = tempdir().unwrap(); + let outside = tempdir().unwrap(); + std::os::unix::fs::symlink(outside.path(), docs.path().join("outside_link")).unwrap(); + let context = ctx_with_docs(dir.path(), docs.path(), AccessMode::FullAccess); + + let write = tools::execute( + "write", + json!({"path": docs.path().join("outside_link/new.md").display().to_string(), "content":"nope"}), + &context, + ) + .await; + assert!(!write.ok); + assert!(write.content.contains("writes are blocked")); + assert!(!outside.path().join("new.md").exists()); +}