Add bundled read-only docs

This commit is contained in:
2026-06-21 11:02:01 -05:00
parent cae390d761
commit 8dca417cd3
19 changed files with 484 additions and 27 deletions
Generated
+20
View File
@@ -161,6 +161,7 @@ dependencies = [
"dirs", "dirs",
"futures-util", "futures-util",
"ignore", "ignore",
"include_dir",
"nanoid", "nanoid",
"ratatui", "ratatui",
"regex", "regex",
@@ -923,6 +924,25 @@ dependencies = [
"winapi-util", "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]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.14.0" version = "2.14.0"
+1
View File
@@ -27,6 +27,7 @@ crossterm = "0.28"
dirs = "5" dirs = "5"
futures-util = "0.3" futures-util = "0.3"
ignore = "0.4" ignore = "0.4"
include_dir = "0.7"
nanoid = "0.4" nanoid = "0.4"
ratatui = "0.28" ratatui = "0.28"
regex = "1" regex = "1"
+4 -2
View File
@@ -43,6 +43,8 @@ Optional config lives at `~/.cass/config.json`:
Extra global instructions can be placed in `~/.cass/global.md`. 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 ## Usage
```sh ```sh
@@ -79,8 +81,8 @@ Resume this chat with: cass --resume <id>
## Tools ## 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. There is no shell/bash tool in v1.
+77
View File
@@ -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::<io::Result<Vec<_>>>()?;
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
}
}
+5
View File
@@ -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.
+4 -1
View File
@@ -59,10 +59,12 @@ pub async fn run_turn(
} }
}; };
let docs_dir = settings.config.docs_dir();
let tool_ctx = ToolContext { let tool_ctx = ToolContext {
mode: settings.mode, mode: settings.mode,
cwd: settings.cwd.clone(), 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, model_result_limit: settings.config.model_tool_result_limit,
}; };
@@ -72,6 +74,7 @@ pub async fn run_turn(
&conversation.base_system_prompt(), &conversation.base_system_prompt(),
settings.mode, settings.mode,
&settings.cwd, &settings.cwd,
&docs_dir,
&settings.config.model, &settings.config.model,
&allowed, &allowed,
); );
+11 -1
View File
@@ -28,10 +28,13 @@ pub struct Config {
pub model_tool_result_limit: usize, pub model_tool_result_limit: usize,
pub ui_tool_result_limit: usize, pub ui_tool_result_limit: usize,
pub root: PathBuf, pub root: PathBuf,
pub docs_dir: PathBuf,
} }
impl Default for Config { impl Default for Config {
fn default() -> Self { fn default() -> Self {
let root = cass_root();
let docs_dir = root.join("docs");
Self { Self {
provider: "openai-compatible".to_string(), provider: "openai-compatible".to_string(),
model: "accounts/fireworks/models/qwen3p7-plus".to_string(), model: "accounts/fireworks/models/qwen3p7-plus".to_string(),
@@ -41,7 +44,8 @@ impl Default for Config {
context_message_limit: 80, context_message_limit: 80,
model_tool_result_limit: 24_000, model_tool_result_limit: 24_000,
ui_tool_result_limit: 4_000, ui_tool_result_limit: 4_000,
root: cass_root(), root,
docs_dir,
} }
} }
} }
@@ -57,8 +61,10 @@ impl Config {
let root = cass_root(); let root = cass_root();
fs::create_dir_all(root.join("conversations")) fs::create_dir_all(root.join("conversations"))
.with_context(|| format!("creating {}", root.join("conversations").display()))?; .with_context(|| format!("creating {}", root.join("conversations").display()))?;
let docs_dir = crate::docs::install(&root)?;
let mut cfg = Config::default(); let mut cfg = Config::default();
cfg.root = root.clone(); cfg.root = root.clone();
cfg.docs_dir = docs_dir;
let path = root.join("config.json"); let path = root.join("config.json");
if path.exists() { if path.exists() {
@@ -118,4 +124,8 @@ impl Config {
pub fn global_path(&self) -> PathBuf { pub fn global_path(&self) -> PathBuf {
self.root.join("global.md") self.root.join("global.md")
} }
pub fn docs_dir(&self) -> PathBuf {
self.docs_dir.clone()
}
} }
+102
View File
@@ -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<PathBuf> {
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<bool> {
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()),
}
}
+1
View File
@@ -4,6 +4,7 @@ pub mod app;
pub mod cli; pub mod cli;
pub mod config; pub mod config;
pub mod conversation; pub mod conversation;
pub mod docs;
pub mod error; pub mod error;
pub mod prompt; pub mod prompt;
pub mod providers; pub mod providers;
+7 -2
View File
@@ -24,6 +24,7 @@ pub fn build_effective_system_prompt(
base: &str, base: &str,
mode: AccessMode, mode: AccessMode,
cwd: &Path, cwd: &Path,
docs_dir: &Path,
model: &str, model: &str,
allowed_tools: &[String], allowed_tools: &[String],
) -> String { ) -> String {
@@ -33,13 +34,17 @@ pub fn build_effective_system_prompt(
prompt.push_str(&format!("Model: {model}.\n")); prompt.push_str(&format!("Model: {model}.\n"));
prompt.push_str(&format!("Access mode: {}.\n", mode.as_str())); prompt.push_str(&format!("Access mode: {}.\n", mode.as_str()));
prompt.push_str(&format!("Launch working directory: {}.\n", cwd.display())); 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!( prompt.push_str(&format!(
"Allowed tools this turn: {}.\n\n", "Allowed tools this turn: {}.\n\n",
allowed_tools.join(", ") allowed_tools.join(", ")
)); ));
match mode { 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::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 paths to the launch directory, but normal operating-system permissions still apply.\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.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 prompt
+3 -2
View File
@@ -19,7 +19,7 @@ struct EditArg {
pub fn spec() -> ToolSpec { pub fn spec() -> ToolSpec {
ToolSpec { ToolSpec {
name: "edit".into(), 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!({ parameters: schema::object(json!({
"path": {"type":"string"}, "path": {"type":"string"},
"edits": {"type":"array", "items": { "edits": {"type":"array", "items": {
@@ -40,7 +40,8 @@ pub fn run(args: Value, ctx: &ToolContext) -> Result<String> {
if args.edits.is_empty() { if args.edits.is_empty() {
bail!("edit requires at least one replacement"); 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 original = fs::read_to_string(&path)?;
let mut ranges: Vec<(Range<usize>, &str)> = Vec::new(); let mut ranges: Vec<(Range<usize>, &str)> = Vec::new();
for edit in &args.edits { for edit in &args.edits {
+2 -2
View File
@@ -31,7 +31,7 @@ fn default_max() -> usize {
pub fn spec() -> ToolSpec { pub fn spec() -> ToolSpec {
ToolSpec { ToolSpec {
name: "grep".into(), 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!({ parameters: schema::object(json!({
"query": {"type":"string"}, "query": {"type":"string"},
"paths": {"type":"array", "items":{"type":"string"}, "default":["."]}, "paths": {"type":"array", "items":{"type":"string"}, "default":["."]},
@@ -62,7 +62,7 @@ pub fn run(args: Value, ctx: &ToolContext) -> Result<String> {
let mut files = Vec::new(); let mut files = Vec::new();
for p in &args.paths { 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)?; collect_files(&path, &mut files)?;
} }
+2 -2
View File
@@ -16,7 +16,7 @@ fn default_path() -> String {
pub fn spec() -> ToolSpec { pub fn spec() -> ToolSpec {
ToolSpec { ToolSpec {
name: "ls".into(), 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(), .into(),
parameters: schema::object( parameters: schema::object(
json!({ json!({
@@ -29,7 +29,7 @@ pub fn spec() -> ToolSpec {
pub fn run(args: Value, ctx: &ToolContext) -> Result<String> { pub fn run(args: Value, ctx: &ToolContext) -> Result<String> {
let args: Args = serde_json::from_value(args)?; 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() { if !path.is_dir() {
bail!("not a directory: {}", path.display()); bail!("not a directory: {}", path.display());
} }
+2 -1
View File
@@ -23,7 +23,8 @@ pub struct ToolSpec {
pub struct ToolContext { pub struct ToolContext {
pub mode: AccessMode, pub mode: AccessMode,
pub cwd: PathBuf, pub cwd: PathBuf,
pub read_only_root: PathBuf, pub read_roots: Vec<PathBuf>,
pub blocked_write_roots: Vec<PathBuf>,
pub model_result_limit: usize, pub model_result_limit: usize,
} }
+99 -8
View File
@@ -1,5 +1,6 @@
use crate::access::AccessMode; use crate::access::AccessMode;
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use std::ffi::OsString;
use std::fs; use std::fs;
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
@@ -15,27 +16,117 @@ pub fn expand_tilde(path: &str) -> PathBuf {
PathBuf::from(path) PathBuf::from(path)
} }
pub fn resolve_existing(input: &str, cwd: &Path, root: &Path, mode: AccessMode) -> Result<PathBuf> { pub fn resolve_existing(
input: &str,
cwd: &Path,
read_roots: &[PathBuf],
mode: AccessMode,
) -> Result<PathBuf> {
let p = expand_tilde(input); let p = expand_tilde(input);
let abs = if p.is_absolute() { p } else { cwd.join(p) }; let abs = if p.is_absolute() { p } else { cwd.join(p) };
let canon = fs::canonicalize(&abs).with_context(|| format!("resolving {}", abs.display()))?; let canon = fs::canonicalize(&abs).with_context(|| format!("resolving {}", abs.display()))?;
if matches!(mode, AccessMode::ReadOnly) { if matches!(mode, AccessMode::ReadOnly) {
let root_canon = ensure_under_any_root(&canon, read_roots)?;
fs::canonicalize(root).with_context(|| format!("resolving root {}", root.display()))?;
if !canon.starts_with(&root_canon) {
bail!("path escapes read-only root: {}", abs.display());
}
} }
Ok(canon) Ok(canon)
} }
pub fn resolve_for_write(input: &str, cwd: &Path, mode: AccessMode) -> Result<PathBuf> { pub fn resolve_for_write(
input: &str,
cwd: &Path,
mode: AccessMode,
blocked_write_roots: &[PathBuf],
) -> Result<PathBuf> {
if !mode.can_write() { if !mode.can_write() {
bail!("write access requires full-access mode"); bail!("write access requires full-access mode");
} }
let p = expand_tilde(input); let p = expand_tilde(input);
let abs = if p.is_absolute() { p } else { cwd.join(p) }; 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::<Vec<_>>()
.join(", ");
bail!(
"path escapes read-only roots: {} (allowed roots: {})",
path.display(),
if allowed.is_empty() {
"<none>"
} 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<PathBuf> {
if path.exists() {
return fs::canonicalize(path).with_context(|| format!("resolving {}", path.display()));
}
let mut missing = Vec::<OsString>::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 { fn normalize_lexical(path: &Path) -> PathBuf {
+2 -3
View File
@@ -18,7 +18,7 @@ struct FileArg {
pub fn spec() -> ToolSpec { pub fn spec() -> ToolSpec {
ToolSpec { ToolSpec {
name: "read".into(), 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!({ parameters: schema::object(json!({
"files": { "files": {
"type":"array", "type":"array",
@@ -43,8 +43,7 @@ pub fn run(args: Value, ctx: &ToolContext) -> Result<String> {
} }
let mut out = String::new(); let mut out = String::new();
for file in args.files { for file in args.files {
let path = let path = super::path::resolve_existing(&file.path, &ctx.cwd, &ctx.read_roots, ctx.mode)?;
super::path::resolve_existing(&file.path, &ctx.cwd, &ctx.read_only_root, ctx.mode)?;
if !path.is_file() { if !path.is_file() {
bail!("not a file: {}", path.display()); bail!("not a file: {}", path.display());
} }
+3 -2
View File
@@ -15,7 +15,7 @@ struct Args {
pub fn spec() -> ToolSpec { pub fn spec() -> ToolSpec {
ToolSpec { ToolSpec {
name: "write".into(), 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!({ parameters: schema::object(json!({
"path": {"type":"string"}, "path": {"type":"string"},
"content": {"type":"string"} "content": {"type":"string"}
@@ -25,7 +25,8 @@ pub fn spec() -> ToolSpec {
pub fn run(args: Value, ctx: &ToolContext) -> Result<String> { pub fn run(args: Value, ctx: &ToolContext) -> Result<String> {
let args: Args = serde_json::from_value(args)?; 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())?; atomic_write(&path, args.content.as_bytes())?;
Ok(format!( Ok(format!(
"wrote {} bytes to {}", "wrote {} bytes to {}",
+20
View File
@@ -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);
}
+119 -1
View File
@@ -7,7 +7,18 @@ fn ctx(root: &std::path::Path, mode: AccessMode) -> ToolContext {
ToolContext { ToolContext {
mode, mode,
cwd: root.to_path_buf(), 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, model_result_limit: 100_000,
} }
} }
@@ -62,6 +73,43 @@ async fn read_and_grep_work() {
assert!(grep.content.contains("a.txt:2")); 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] #[tokio::test]
async fn edit_is_exact_and_atomic_on_validation_failure() { async fn edit_is_exact_and_atomic_on_validation_failure() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
@@ -92,3 +140,73 @@ async fn edit_is_exact_and_atomic_on_validation_failure() {
"alpha\nBETA\ngamma\n" "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());
}