Add bundled read-only docs
This commit is contained in:
Generated
+20
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {}",
|
||||||
|
|||||||
@@ -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
@@ -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());
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user