Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
### Changed

- Internal: scope-isolation regression gate, parts 1 and 2 (#1467): the HTTP suite runs 20 scoped read requests through `route` (every recall strategy, search, list, recent, export, graph, room, orphans, tags, stats, doc) and a test fails when a read route is added to the registry without an isolation check or a documented exemption. Part 1: a two-namespace fixture with overlapping vocabulary and a core suite asserting that `recall` (every strategy), unified recall, `search`, `list`, tags, export, room recall and graph views never return a record from another namespace. The fixture ships behind the `test-support` feature of `uteke-core` for the server, MCP and CLI suites that follow.
- Internal: scope-isolation regression gate, part 3 (#1467): the MCP suite over the read-only tools (a test fails when a tool is added without an isolation check or an exemption), a CLI suite that runs the binary against a two-namespace store, and `docs/isolation-suite.md`, which lists what each transport covers and the exemptions.
- **`tools/list` for a read-only token lists only the tools it can call (#1446)** - MCP over HTTP advertised every tool to read-only callers, which then failed at call time. A read-only caller now sees exactly the read tools; full-access callers, the stdio transport and `tools/call` are unchanged. Contract: uteke-cloud#203 (approved by the owner).
- **`unsafe_code` is now enforced (#1456)** - the workspace declared `unsafe_code = "forbid"` but no crate inherited it, so nothing was enforced. Every crate now has `[lints] workspace = true` and the level is `deny`: production code cannot contain `unsafe`, and only the five test modules that call `std::env::set_var`/`remove_var` (edition 2024) opt out with `#[allow(unsafe_code)]`. `forbid` cannot be used because it cannot be relaxed per module.

Expand Down
172 changes: 172 additions & 0 deletions crates/uteke-cli/tests/scope_isolation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
//! Scope-isolation regression gate over the CLI (#1467, part 3).
//!
//! Seeds a store with two namespaces (`alpha`, `beta`) whose memories share one
//! vocabulary, then reads it through every CLI command that takes a scope. Each
//! memory ends in `of <namespace>`, so output scoped to one namespace must hold
//! its own marker and never the other's, nor the other's project tag.
//!
//! `uteke recall` needs the ONNX runtime, which CI does not have (memories are
//! stored FTS-only here, see `json_output_stays_clean`); the core, HTTP and MCP
//! suites cover vector recall. `docs/isolation-suite.md` lists the coverage.

use std::path::PathBuf;
use std::process::Command;

const Q: &str = "quartz meridian orchid ledger";
const SCOPES: [&str; 2] = ["alpha", "beta"];

struct Store {
dir: PathBuf,
}

impl Store {
fn new(name: &str) -> Self {
let dir =
std::env::temp_dir().join(format!("uteke_isolation_{name}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("home")).unwrap();
// Server routing off, so every command runs against the local store.
std::fs::write(dir.join("home/uteke.toml"), "[server]\nenabled = false\n").unwrap();
Self { dir }
}

fn run(&self, args: &[&str]) -> String {
let out = Command::new(env!("CARGO_BIN_EXE_uteke"))
.arg("--store")
.arg(self.dir.join("store"))
.args(args)
.env("UTEKE_HOME", self.dir.join("home"))
.env("UTEKE_NO_UPDATE_CHECK", "1")
// No embedder: memories are stored FTS-only, commands still run.
.env("ORT_LIB_PATH", "/nonexistent/uteke-test/libonnxruntime.so")
.env_remove("UTEKE_AUTH_TOKEN")
.output()
.expect("run uteke");
assert!(
out.status.success(),
"uteke {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8(out.stdout).unwrap()
}

fn seed(&self) {
for scope in SCOPES {
let tags = format!("project:{scope},shared");
for i in 0..3 {
let text = format!("{Q} note {i} of {scope}");
self.run(&[
"--json",
"remember",
&text,
"--namespace",
scope,
"--tags",
&tags,
]);
}
let room = format!("room-{scope}");
let text = format!("{Q} room entry of {scope}");
self.run(&[
"--json",
"remember",
&text,
"--namespace",
scope,
"--tags",
&tags,
"--room",
&room,
"--author",
"tester",
]);
// Unrelated vocabulary: the other memories link to each other, this
// one stays an orphan.
let text = format!("solitary zebra fjord of {scope}");
self.run(&["--json", "remember", &text, "--namespace", scope]);
}
}
}

impl Drop for Store {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}

/// (arguments, the output must hold an `of <scope>` marker).
fn surfaces(scope: &str) -> Vec<(Vec<String>, bool)> {
let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
let room = format!("room-{scope}");
vec![
(
s(&["--json", "search", Q, "--namespace", scope, "--limit", "20"]),
true,
),
(
s(&["--json", "list", "--namespace", scope, "--limit", "50"]),
true,
),
(
s(&[
"--json",
"list",
"--tag",
"shared",
"--namespace",
scope,
"--limit",
"50",
]),
true,
),
(s(&["--json", "export", "-", "--namespace", scope]), true),
(s(&["--json", "room", "recall", &room]), true),
(
s(&[
"--json",
"orphans",
"--threshold",
"2",
"--namespace",
scope,
]),
true,
),
(s(&["--json", "tags", "list", "--namespace", scope]), false),
(s(&["--json", "stats", "--namespace", scope]), false),
(s(&["--json", "room", "list", "--namespace", scope]), false),
(
s(&["--json", "contradictions", "list", "--namespace", scope]),
false,
),
]
}

#[test]
fn every_scoped_read_command_returns_only_its_own_namespace() {
let store = Store::new("read");
store.seed();
for scope in SCOPES {
let foreign = if scope == "alpha" { "beta" } else { "alpha" };
for (args, expects_hits) in surfaces(scope) {
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
let out = store.run(&refs);
let shown: String = out.chars().take(300).collect();
assert!(
!out.contains(&format!("of {foreign}")),
"uteke {args:?} scoped to {scope} returned {foreign} records: {shown}"
);
assert!(
!out.contains(&format!("project:{foreign}")),
"uteke {args:?} scoped to {scope} exposed the {foreign} project tag: {shown}"
);
if expects_hits {
assert!(
out.contains(&format!("of {scope}")),
"uteke {args:?} found no {scope} record, the check would be vacuous: {shown}"
);
}
}
}
}
1 change: 1 addition & 0 deletions crates/uteke-mcp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ uuid = "1"
[dev-dependencies]
uuid = { version = "1", features = ["v4"] }
chrono = "0.4"
uteke-core = { path = "../uteke-core", features = ["test-support"] }

[lints]
workspace = true
180 changes: 180 additions & 0 deletions crates/uteke-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3968,3 +3968,183 @@ mod read_only_gate_tests {
assert_eq!(u.count(None).unwrap(), 1);
}
}

/// Scope-isolation regression gate over MCP (#1467, part 3).
///
/// The two-namespace store of `uteke_core::test_support::seed_two_scopes`
/// (records of `alpha` and `beta` share one vocabulary, each ends in
/// `of <namespace>`) is read through every read-only tool that takes a scope
/// and must never return the other namespace's records or project tag.
/// `every_read_tool_is_covered_or_exempt` fails when a read-only tool is added
/// without an isolation check or a documented exemption.
#[cfg(test)]
mod isolation_tests {
use super::*;
use serde_json::{Value, json};
use uteke_core::test_support::{ISOLATION_QUERY as Q, ISOLATION_SCOPES, seed_two_scopes};

fn call(u: &Uteke, tool: &str, args: Value) -> String {
let raw = json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": tool, "arguments": args}
})
.to_string();
handle_jsonrpc_as(u, &raw, Access::ReadOnly).expect("a response")
}

/// (tool, arguments, the response must hold an `of <scope>` marker).
fn surfaces(scope: &str) -> Vec<(&'static str, Value, bool)> {
let mut v = vec![
(
"uteke_search",
json!({"query": Q, "namespace": scope, "limit": 20}),
true,
),
("uteke_list", json!({"namespace": scope, "limit": 50}), true),
(
"uteke_list",
json!({"namespace": scope, "tag": "shared", "limit": 50}),
true,
),
("uteke_graph", json!({"namespace": scope}), true),
(
"uteke_room_recall",
json!({"room_id": format!("room-{scope}"), "query": Q}),
true,
),
(
"uteke_room_memories",
json!({"room_id": format!("room-{scope}")}),
true,
),
("uteke_tags_list", json!({"namespace": scope}), false),
("uteke_room_list", json!({"namespace": scope}), false),
("uteke_stats", json!({"namespace": scope}), false),
("uteke_contradictions", json!({"namespace": scope}), false),
];
for strategy in ["hybrid", "vector", "fts5", "graph", "fusion"] {
for kind in ["memory", "all"] {
v.push((
"uteke_recall",
json!({"query": Q, "namespace": scope, "limit": 20, "strategy": strategy, "type": kind}),
true,
));
}
}
v.push((
"uteke_recall",
json!({"query": Q, "namespace": scope, "pack": true, "budget_chars": 4000}),
true,
));
v.push((
"uteke_recall",
json!({"query": Q, "namespace": scope, "explain": true}),
true,
));
v.push((
"uteke_recall",
json!({"query": Q, "namespace": scope, "tags": ["shared"]}),
true,
));
v
}

#[test]
fn every_scoped_read_tool_returns_only_its_own_namespace() {
let u = seed_two_scopes();
for scope in ISOLATION_SCOPES {
let foreign = if scope == "alpha" { "beta" } else { "alpha" };
for (tool, args, expects_hits) in surfaces(scope) {
let text = call(&u, tool, args.clone());
let what = format!("{tool} {args}");
let shown: String = text.chars().take(300).collect();
assert!(!text.contains("\"isError\":true"), "{what}: {shown}");
assert!(
!text.contains(&format!("of {foreign}")),
"{what} scoped to {scope} returned {foreign} records: {shown}"
);
assert!(
!text.contains(&format!("project:{foreign}")),
"{what} scoped to {scope} exposed the {foreign} project tag: {shown}"
);
if expects_hits {
assert!(
text.contains(&format!("of {scope}")),
"{what} found no {scope} record, the check would be vacuous: {shown}"
);
}
}
}
}

/// Read-only tools the suite does not scope-check, each with the reason.
const EXEMPT: &[(&str, &str)] = &[
("uteke_get", "fetch by ID: IDs are global handles"),
("uteke_provenance", "by memory ID"),
("uteke_timeline", "by memory ID"),
(
"uteke_doc_get",
"by document slug; documents are global (#1271)",
),
(
"uteke_doc_list",
"documents are global (#1271), no memory content",
),
(
"uteke_doc_search",
"documents are global (#1271), no memory content",
),
("uteke_doc_list_rooms", "by document slug"),
("uteke_room_stats", "by room ID; the room is the scope"),
("uteke_room_summary", "by room ID; the room is the scope"),
(
"uteke_room_summary_document",
"by room ID; the room is the scope",
),
(
"uteke_room_document",
"dispatch alias of uteke_room_summary_document, never advertised",
),
(
"uteke_room_list_documents",
"by room ID; documents are global",
),
];

/// Every read-only tool must be covered by `surfaces` or exempt with a
/// reason, and a tool that advertises a `namespace` argument can never be
/// exempt: a new retrieval tool cannot skip the isolation suite unnoticed.
#[test]
fn every_read_tool_is_covered_or_exempt() {
let covered: std::collections::BTreeSet<&str> =
surfaces("alpha").iter().map(|(t, _, _)| *t).collect();
let exempt: std::collections::BTreeSet<&str> = EXEMPT.iter().map(|(t, _)| *t).collect();
for tool in READ_ONLY_TOOLS {
assert!(
covered.contains(tool) || exempt.contains(tool),
"{tool} is a read-only tool with no isolation check: add it to `surfaces` or to `EXEMPT` with a reason (#1467)"
);
}
for tool in covered.iter().chain(exempt.iter()) {
assert!(
READ_ONLY_TOOLS.contains(tool),
"{tool} is not a read-only tool"
);
}
assert!(
covered.is_disjoint(&exempt),
"a tool is both covered and exempt"
);
let u = seed_two_scopes();
let list = handle_request(&u, "tools/list", None, Access::ReadOnly).unwrap();
for t in list["tools"].as_array().unwrap() {
let name = t["name"].as_str().unwrap();
if t["inputSchema"]["properties"]["namespace"].is_object() {
assert!(
covered.contains(name),
"{name} takes a namespace but is not covered by the isolation suite"
);
}
}
}
}
Loading
Loading