Add bundled read-only docs
This commit is contained in:
Generated
+20
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 <id>
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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 {
|
||||
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,
|
||||
);
|
||||
|
||||
+11
-1
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+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 config;
|
||||
pub mod conversation;
|
||||
pub mod docs;
|
||||
pub mod error;
|
||||
pub mod prompt;
|
||||
pub mod providers;
|
||||
|
||||
+7
-2
@@ -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
|
||||
|
||||
+3
-2
@@ -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<String> {
|
||||
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<usize>, &str)> = Vec::new();
|
||||
for edit in &args.edits {
|
||||
|
||||
+2
-2
@@ -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<String> {
|
||||
|
||||
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)?;
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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<String> {
|
||||
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());
|
||||
}
|
||||
|
||||
+2
-1
@@ -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<PathBuf>,
|
||||
pub blocked_write_roots: Vec<PathBuf>,
|
||||
pub model_result_limit: usize,
|
||||
}
|
||||
|
||||
|
||||
+99
-8
@@ -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<PathBuf> {
|
||||
pub fn resolve_existing(
|
||||
input: &str,
|
||||
cwd: &Path,
|
||||
read_roots: &[PathBuf],
|
||||
mode: AccessMode,
|
||||
) -> Result<PathBuf> {
|
||||
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<PathBuf> {
|
||||
pub fn resolve_for_write(
|
||||
input: &str,
|
||||
cwd: &Path,
|
||||
mode: AccessMode,
|
||||
blocked_write_roots: &[PathBuf],
|
||||
) -> Result<PathBuf> {
|
||||
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::<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 {
|
||||
|
||||
+2
-3
@@ -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<String> {
|
||||
}
|
||||
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());
|
||||
}
|
||||
|
||||
+3
-2
@@ -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<String> {
|
||||
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 {}",
|
||||
|
||||
@@ -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 {
|
||||
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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user