26382a7ac6
CI / Clippy (push) Failing after 15m13s
CI / Test (ubuntu-latest) (push) Failing after 16m1s
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Build (no embeddings / no ORT) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Cookbook (Node) (push) Has been cancelled
CI / Pi Extension (Node) (push) Has been cancelled
CI / Rust SDK (lean-ctx-client) (push) Has been cancelled
CI / Embed SDK (lean-ctx-sdk) (push) Has been cancelled
CI / Python SDK (leanctx) (push) Has been cancelled
CI / Hermes Plugin (Python) (push) Has been cancelled
CI / SDK Conformance Matrix (push) Has been cancelled
CI / Coverage (push) Has been cancelled
CI / cargo-deny (push) Has been cancelled
CI / Adversarial Safety (push) Has been cancelled
CI / Benchmarks (push) Has been cancelled
CI / Output-Quality Gate (eval A/B) (push) Has been cancelled
CI / Documentation (push) Has been cancelled
CI / CI Green (push) Has been cancelled
JetBrains Plugin / Actionlint (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (rust) (push) Has been cancelled
JetBrains Plugin / Validation (push) Has been cancelled
JetBrains Plugin / Build (push) Has been cancelled
JetBrains Plugin / Test (push) Has been cancelled
Security Check / Security Scan (push) Has been cancelled
157 lines
5.9 KiB
Rust
157 lines
5.9 KiB
Rust
//! #475 end-to-end: a configured read-only root (`read_only_roots` /
|
|
//! `LEAN_CTX_READ_ONLY_ROOTS`) is fully **readable** through the real
|
|
//! `ContextEngine` tool dispatch — the same path the MCP server drives — yet
|
|
//! **no write tool can mutate it**, while ordinary writes inside the project
|
|
//! root keep working.
|
|
//!
|
|
//! This is the integration-level counterpart to the unit guards at every write
|
|
//! choke point: it exercises `resolve_path` (jail allow-list widened by the
|
|
//! read-only roots) → tool `handle` → `pathjail::enforce_writable`, proving the
|
|
//! tier holds across the *whole* request, not just the low-level primitive.
|
|
//!
|
|
//! A prior attempt (#464) was rejected because writes could escape the
|
|
//! read-only tier; this test is the regression gate against that recurring.
|
|
#![cfg(not(feature = "no-jail"))]
|
|
// The test_env_lock guard is intentionally held across the async tool calls to
|
|
// serialize the process-global env mutation (LEAN_CTX_READ_ONLY_ROOTS) against
|
|
// other tests — the same pattern as power_user_worksession.rs.
|
|
#![allow(clippy::await_holding_lock)]
|
|
|
|
use serde_json::json;
|
|
|
|
/// Mutating the process environment is unsafe in Rust 2024 because it is not
|
|
/// thread-safe; this test serializes every environment access through
|
|
/// `test_env_lock`, so the precondition holds.
|
|
fn set_env(key: &str, value: &std::path::Path) {
|
|
// SAFETY: the caller holds `test_env_lock` for the whole test, so no other
|
|
// thread reads or writes the environment concurrently.
|
|
unsafe { std::env::set_var(key, value) };
|
|
}
|
|
|
|
fn clear_env(key: &str) {
|
|
// SAFETY: see `set_env`.
|
|
unsafe { std::env::remove_var(key) };
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn read_only_root_is_readable_but_never_writable_via_engine() {
|
|
let _lock = lean_ctx::core::data_dir::test_env_lock();
|
|
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let project = dir.path().join("project");
|
|
let refrepo = dir.path().join("refrepo");
|
|
std::fs::create_dir_all(project.join("src")).expect("project/src");
|
|
std::fs::create_dir_all(&refrepo).expect("refrepo");
|
|
// A project marker keeps the jail root stable (no auto-reroot heuristics).
|
|
std::fs::write(
|
|
project.join("Cargo.toml"),
|
|
"[package]\nname = \"p\"\nversion = \"0.0.0\"\nedition = \"2021\"\n",
|
|
)
|
|
.expect("Cargo.toml");
|
|
|
|
let proj_file = project.join("src/main.rs");
|
|
std::fs::write(&proj_file, "fn main() { let x = 1; }\n").expect("main.rs");
|
|
|
|
let ref_file = refrepo.join("lib.rs");
|
|
let ref_original = "pub fn shared_secret() -> i32 { 42 }\n";
|
|
std::fs::write(&ref_file, ref_original).expect("refrepo/lib.rs");
|
|
|
|
let data_dir = dir.path().join("data");
|
|
std::fs::create_dir_all(&data_dir).expect("data");
|
|
set_env("LEAN_CTX_DATA_DIR", &data_dir);
|
|
// The feature under test: the sibling repo is a read-only root.
|
|
set_env("LEAN_CTX_READ_ONLY_ROOTS", &refrepo);
|
|
|
|
let engine = lean_ctx::engine::ContextEngine::with_project_root(&project);
|
|
|
|
// 1) READ of a file in the read-only root must resolve and return content
|
|
// (the whole point of read-only roots: read sibling repos).
|
|
let read_out = engine
|
|
.call_tool_text(
|
|
"ctx_read",
|
|
Some(json!({ "path": ref_file.to_string_lossy() })),
|
|
)
|
|
.await
|
|
.expect("ctx_read call");
|
|
|
|
// 2) WRITE (edit) into the read-only root must be refused.
|
|
let edit_ro = engine
|
|
.call_tool_text(
|
|
"ctx_edit",
|
|
Some(json!({
|
|
"path": ref_file.to_string_lossy(),
|
|
"old_string": "42",
|
|
"new_string": "999",
|
|
})),
|
|
)
|
|
.await
|
|
.expect("ctx_edit (read-only) call");
|
|
|
|
// 3) CREATE of a new file inside the read-only root must be refused too.
|
|
let create_ro = engine
|
|
.call_tool_text(
|
|
"ctx_edit",
|
|
Some(json!({
|
|
"path": refrepo.join("injected.rs").to_string_lossy(),
|
|
"old_string": "",
|
|
"new_string": "pub fn injected() {}\n",
|
|
"create": true,
|
|
})),
|
|
)
|
|
.await
|
|
.expect("ctx_edit (create in read-only) call");
|
|
|
|
// 4) CONTROL: a normal edit inside the project root still works.
|
|
let edit_ok = engine
|
|
.call_tool_text(
|
|
"ctx_edit",
|
|
Some(json!({
|
|
"path": proj_file.to_string_lossy(),
|
|
"old_string": "let x = 1;",
|
|
"new_string": "let x = 2;",
|
|
})),
|
|
)
|
|
.await
|
|
.expect("ctx_edit (project) call");
|
|
|
|
// Snapshot disk state, then drop the env before asserting so a failure can
|
|
// never leak the read-only root into a parallel test sharing the lock.
|
|
let ref_after = std::fs::read_to_string(&ref_file).expect("read refrepo file");
|
|
let proj_after = std::fs::read_to_string(&proj_file).expect("read project file");
|
|
let injected_exists = refrepo.join("injected.rs").exists();
|
|
clear_env("LEAN_CTX_READ_ONLY_ROOTS");
|
|
clear_env("LEAN_CTX_DATA_DIR");
|
|
|
|
// Reads are allowed.
|
|
assert!(
|
|
read_out.contains("shared_secret"),
|
|
"a read of a read-only root must return its content: {read_out}"
|
|
);
|
|
|
|
// Every write into the read-only root is denied and names the tier.
|
|
assert!(
|
|
edit_ro.contains("read-only"),
|
|
"editing a file in a read-only root must be refused: {edit_ro}"
|
|
);
|
|
assert!(
|
|
create_ro.contains("read-only"),
|
|
"creating a file in a read-only root must be refused: {create_ro}"
|
|
);
|
|
|
|
// The read-only tree is byte-identical — nothing leaked through any tool.
|
|
assert_eq!(
|
|
ref_after, ref_original,
|
|
"the read-only file must be untouched"
|
|
);
|
|
assert!(
|
|
!injected_exists,
|
|
"no file may be created inside a read-only root"
|
|
);
|
|
|
|
// The project write applied normally — the feature does not break writes.
|
|
assert!(
|
|
proj_after.contains("let x = 2;"),
|
|
"a normal project edit must still apply: {edit_ok}"
|
|
);
|
|
}
|