From 31870bee4b791c3e8084b49119b7ab599dd05c3c Mon Sep 17 00:00:00 2001 From: Larry Klosowski Date: Mon, 19 Oct 2026 10:44:34 -0700 Subject: [PATCH 1/4] feat(sidecar): per-session host tool-set allowlist (for Store daemons) POST /sessions accepts an optional `toolSets` allowlist of sidecar-hosted tool sets (web_search, web_browse, mcp, toolchain, skills_read, learn_propose, capsules_run, files, shell_run). When present, only those sets are offered and callable (deny by default; [] = none); the others are absent from the tool list and a call is refused. Unknown names refuse the session. Absent = unchanged. `promptSnapshot: false` keeps the system prompt exactly as core sent it (no per-turn skill index; a persona is refused). GET /status reports `capabilities.sessionToolSets` and `capabilities.sessionPromptSnapshot` so core can detect support. For citrate-core #356 (Store v1). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0148EsKJD57GeaM8JwAraU3N --- agent-sidecar/src/host_sets.rs | 180 +++++++ agent-sidecar/src/host_sets_session_tests.rs | 473 +++++++++++++++++++ agent-sidecar/src/lib.rs | 28 ++ agent-sidecar/src/sessions.rs | 235 ++++++--- 4 files changed, 842 insertions(+), 74 deletions(-) create mode 100644 agent-sidecar/src/host_sets.rs create mode 100644 agent-sidecar/src/host_sets_session_tests.rs diff --git a/agent-sidecar/src/host_sets.rs b/agent-sidecar/src/host_sets.rs new file mode 100644 index 0000000..55dc97c --- /dev/null +++ b/agent-sidecar/src/host_sets.rs @@ -0,0 +1,180 @@ +//! Per-session host tool-set allowlist (`toolSets` on `POST /sessions`), for Store daemons +//! (citrate-core #356). +//! +//! The sidecar hosts tool sets of its own (search, the browser, MCP servers, the contract +//! toolchain, installed skills, skill learning, installed capsules, the grant-document file +//! tools and `shell_run`). Without an allowlist a session gets every set the sidecar process was +//! started with, as before. With one, the session gets ONLY the sets it names (deny by default): +//! every other set's tools are absent from the tool list, and a call to one is refused (the loop +//! answers "unknown tool", and the session's sidecar host has no executor for it either). +//! +//! The set names are citrate-core's permission names for these sets +//! (`docs/STORE_CATALOG.md`, "Tools the Hermes sidecar hosts itself"), so core passes a Store +//! daemon's approved permissions through unchanged. `files` and `shell_run` have no Store +//! permission in core today; they are named so that an allowlist can leave them out explicitly. + +/// One sidecar-hosted tool set. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HostSet { + /// `web_search`, `read_url` (`CITRATE_HERMES_SEARCH=1`). + WebSearch, + /// `browser_*` (`CITRATE_HERMES_BROWSER=1`). + WebBrowse, + /// `mcp____` (`CITRATE_HERMES_MCP`). + Mcp, + /// `forge_test`, `slither_scan`, `aderyn_scan`, `medusa_fuzz` (`CITRATE_HERMES_TOOLCHAIN=1`). + Toolchain, + /// `skill_load` and the per-turn skill index (`CITRATE_HERMES_SKILLS`). + SkillsRead, + /// `learn_propose` (`CITRATE_HERMES_LEARN_DIR`). + LearnPropose, + /// Installed capsules (`CITRATE_HERMES_CAPSULES`): every sidecar-hosted spec core passes. + CapsulesRun, + /// `file_*`, `sheet_*`, `fs_*`, `project_create` (need a grant document). + Files, + /// `shell_run` (needs `CITRATE_HERMES_SHELL_RUN=1` and a grant document). + ShellRun, +} + +impl HostSet { + /// Every set, in the order the capability lists them. + pub const ALL: [HostSet; 9] = [ + HostSet::WebSearch, + HostSet::WebBrowse, + HostSet::Mcp, + HostSet::Toolchain, + HostSet::SkillsRead, + HostSet::LearnPropose, + HostSet::CapsulesRun, + HostSet::Files, + HostSet::ShellRun, + ]; + + /// The canonical wire name. + pub fn name(self) -> &'static str { + match self { + HostSet::WebSearch => "web_search", + HostSet::WebBrowse => "web_browse", + HostSet::Mcp => "mcp", + HostSet::Toolchain => "toolchain", + HostSet::SkillsRead => "skills_read", + HostSet::LearnPropose => "learn_propose", + HostSet::CapsulesRun => "capsules_run", + HostSet::Files => "files", + HostSet::ShellRun => "shell_run", + } + } + + /// The set a wire name names (exact match only). + pub fn parse(name: &str) -> Option { + HostSet::ALL.into_iter().find(|s| s.name() == name) + } + + fn bit(self) -> u16 { + 1 << (self as u16) + } +} + +/// Every canonical set name, as `GET /status` reports them under `capabilities`. +pub fn set_names() -> Vec<&'static str> { + HostSet::ALL.into_iter().map(HostSet::name).collect() +} + +/// The host sets one session may use. `Unrestricted` = no allowlist was given (every set the +/// sidecar runs with, as before). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HostSets { + Unrestricted, + Only(u16), +} + +impl HostSets { + /// From a session's `toolSets`: absent = unrestricted; present = exactly those sets. An + /// unknown name refuses the whole request (nothing is guessed). + pub fn from_allowlist(names: Option<&[String]>) -> Result { + let Some(names) = names else { + return Ok(HostSets::Unrestricted); + }; + let mut bits = 0u16; + for n in names { + let set = HostSet::parse(n).ok_or_else(|| { + format!( + "toolSets names an unknown tool set '{n}'; this sidecar knows: {}", + set_names().join(", ") + ) + })?; + bits |= set.bit(); + } + Ok(HostSets::Only(bits)) + } + + /// Whether this session may use `set`. + pub fn allows(self, set: HostSet) -> bool { + match self { + HostSets::Unrestricted => true, + HostSets::Only(bits) => bits & set.bit() != 0, + } + } + + /// The allowed set names in canonical order, or `None` when unrestricted. + pub fn names(self) -> Option> { + match self { + HostSets::Unrestricted => None, + HostSets::Only(_) => Some( + HostSet::ALL + .into_iter() + .filter(|s| self.allows(*s)) + .map(HostSet::name) + .collect(), + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn names_round_trip_and_match_core_permission_names() { + // citrate-core docs/STORE_CATALOG.md "Covering permission" column, plus files/shell_run. + assert_eq!( + set_names(), + vec![ + "web_search", + "web_browse", + "mcp", + "toolchain", + "skills_read", + "learn_propose", + "capsules_run", + "files", + "shell_run", + ] + ); + for s in HostSet::ALL { + assert_eq!(HostSet::parse(s.name()), Some(s)); + } + } + + #[test] + fn absent_is_unrestricted_empty_allows_nothing_unknown_is_refused() { + let all = HostSets::from_allowlist(None).expect("absent"); + assert!(HostSet::ALL.into_iter().all(|s| all.allows(s))); + assert_eq!(all.names(), None); + + let none = HostSets::from_allowlist(Some(&[])).expect("empty"); + assert!(HostSet::ALL.into_iter().all(|s| !none.allows(s))); + assert_eq!(none.names(), Some(vec![])); + + let one = HostSets::from_allowlist(Some(&["web_search".to_string()])).expect("one"); + assert!(one.allows(HostSet::WebSearch)); + assert!(HostSet::ALL + .into_iter() + .filter(|s| *s != HostSet::WebSearch) + .all(|s| !one.allows(s))); + + let err = HostSets::from_allowlist(Some(&["search".to_string()])).unwrap_err(); + assert!(err.contains("unknown tool set 'search'"), "{err}"); + } +} diff --git a/agent-sidecar/src/host_sets_session_tests.rs b/agent-sidecar/src/host_sets_session_tests.rs new file mode 100644 index 0000000..21f412e --- /dev/null +++ b/agent-sidecar/src/host_sets_session_tests.rs @@ -0,0 +1,473 @@ +//! Per-session host tool-set allowlist (`toolSets`, `promptSnapshot`) for Store daemons +//! (citrate-core #356). The manager runs with search, skills, the toolchain, learning and folder +//! grants all on, and core passes one sidecar-hosted (capsule) spec, so every case shows what an +//! allowlist removes from a session that would otherwise get all of them. + +use super::host_sets::set_names; +use super::toolchain::{ToolchainConfig, ToolchainHost}; +use super::*; +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use citrate_agent_grants::FolderGrants; +use citrate_agent_loop::skills::{SkillLibrary, SkillSource, SKILL_LOAD_TOOL}; +use citrate_agent_loop::{AssistantTurn, CompletionRequest, LlmClient, LlmError, ToolCall}; +use citrate_agent_search::{ReadUrlConfig, SearchConfig, SearchHost}; +use citrate_agent_shell::sandbox::SandboxMode; +use std::sync::Mutex; +use std::time::Duration; +use tower::ServiceExt; + +const BEARER: &str = "test-bearer-token-0123456789"; +const PROMPT: &str = "You are Hermes."; + +struct Recorder { + turns: Mutex>, + seen: Mutex>, +} +impl LlmClient for Recorder { + fn complete(&self, req: &CompletionRequest) -> Result { + if let Ok(mut s) = self.seen.lock() { + s.push(req.clone()); + } + let mut t = self + .turns + .lock() + .map_err(|_| LlmError::Transport("lock".into()))?; + Ok(if t.is_empty() { + AssistantTurn::text("(done)") + } else { + t.remove(0) + }) + } +} + +/// A scratch tree: `home/` (grants home), `skills/deploy-checklist/SKILL.md`, `learn/`, +/// `tc/root` (toolchain root) and `tc/bin` (an empty toolchain search path). +struct Fx { + dir: tempfile::TempDir, +} +impl Fx { + fn new() -> Self { + let dir = tempfile::tempdir().expect("tempdir"); + for d in [ + "home", + "skills/deploy-checklist", + "learn", + "tc/root", + "tc/bin", + "tc/home", + ] { + std::fs::create_dir_all(dir.path().join(d)).expect("mkdir"); + } + std::fs::write( + dir.path().join("skills/deploy-checklist/SKILL.md"), + "---\nname: deploy-checklist\ndescription: Checks a contract before deploy\n---\n1. Run the tests.\n", + ) + .expect("skill"); + Fx { dir } + } + fn p(&self, rel: &str) -> std::path::PathBuf { + self.dir.path().join(rel) + } + /// A valid grant document with no folders granted (enough for the file tools to be offered). + fn grants_doc(&self) -> serde_json::Value { + let home = self.p("home"); + serde_json::to_value(FolderGrants::new(home.clone(), home).state()).expect("doc") + } +} + +fn state(fx: &Fx, turns: Vec) -> (Arc, Arc) { + let rec = Arc::new(Recorder { + turns: Mutex::new(turns), + seen: Mutex::new(vec![]), + }); + let r2 = rec.clone(); + let search = SearchConfig { + read: ReadUrlConfig { + allow_private: vec!["127.0.0.1".parse().expect("ip")], + ..ReadUrlConfig::default() + }, + ..SearchConfig::default() + }; + let toolchain = ToolchainHost::new(ToolchainConfig { + roots: vec![fx.p("tc/root")], + search_path: vec![fx.p("tc/bin")], + solc: None, + home: fx.p("tc/home"), + sandbox: SandboxMode::Off, + }) + .expect("toolchain"); + let learn = + crate::learn::LearnService::open(&fx.p("learn"), &fx.p("skills"), vec![]).expect("learn"); + let mgr = sessions::SessionManager::new( + Arc::new(move |_ep: &sessions::LlmEndpoint| r2.clone() as Arc), + Duration::from_secs(5), + ) + .with_search(Arc::new(SearchHost::new(search))) + .with_skills(Arc::new(SkillLibrary::load(&[SkillSource::new( + "user", + fx.p("skills"), + )]))) + .with_toolchain(Arc::new(toolchain)) + .with_learn(Arc::new(learn)) + .with_grants_home(fx.p("home")); + let st = Arc::new(AppState { + estop: EmergencyStop::new(), + queue: Arc::new(ApprovalQueue::new()), + skills: vec![], + dispatch: None, + bearer: BEARER.to_string(), + run_slots: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_SKILLS)), + sessions: Arc::new(mgr), + }); + (st, rec) +} + +fn req(method: &str, path: &str, body: serde_json::Value) -> Request { + Request::builder() + .method(method) + .uri(path) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {BEARER}")) + .body(Body::from(body.to_string())) + .expect("request") +} + +async fn json(resp: axum::response::Response) -> serde_json::Value { + let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20) + .await + .unwrap_or_default(); + serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null) +} + +/// A session body with one core tool, one core-passed capsule spec (`host: sidecar`) and a grant +/// document; `extra` is merged in (`toolSets`, `promptSnapshot`, `persona`). +fn body(fx: &Fx, extra: serde_json::Value) -> serde_json::Value { + let mut b = serde_json::json!({ + "model": "gemma-4", + "systemPrompt": PROMPT, + "llm": {"baseUrl": "http://127.0.0.1:18080/v1", "bearer": "k"}, + "tools": [ + {"name": "node_status", "description": "Read the node status", "parameters": {"type": "object"}, "host": "core"}, + {"name": "hello_capsule", "description": "An installed capsule", "parameters": {"type": "object"}, "host": "sidecar"} + ], + "grants": fx.grants_doc(), + "maxToolsPerRequest": 64 + }); + if let (Some(o), Some(e)) = (b.as_object_mut(), extra.as_object()) { + for (k, v) in e { + o.insert(k.clone(), v.clone()); + } + } + b +} + +async fn open(st: &Arc, b: serde_json::Value) -> (StatusCode, serde_json::Value) { + let r = app(st.clone()) + .oneshot(req("POST", "/sessions", b)) + .await + .expect("create"); + let status = r.status(); + (status, json(r).await) +} + +async fn run(st: &Arc, id: &str, text: &str) -> Vec { + let r = app(st.clone()) + .oneshot(req( + "POST", + &format!("/sessions/{id}/messages"), + serde_json::json!({ "text": text }), + )) + .await + .expect("send"); + assert_eq!(r.status(), StatusCode::ACCEPTED); + let mut all = vec![]; + let mut after = 0u64; + for _ in 0..200 { + let r = app(st.clone()) + .oneshot(req( + "GET", + &format!("/sessions/{id}/events?after={after}&wait_ms=100"), + serde_json::Value::Null, + )) + .await + .expect("events"); + let page = json(r).await; + for e in page["events"].as_array().cloned().unwrap_or_default() { + after = after.max(e["seq"].as_u64().unwrap_or(0)); + all.push(e["event"].clone()); + } + if all.iter().any(|e| e["type"] == "done") { + return all; + } + } + panic!("no done event; got {all:?}"); +} + +fn offered(rec: &Recorder) -> Vec { + let mut v: Vec = rec + .seen + .lock() + .map(|s| s[0].tools.iter().map(|t| t.name.clone()).collect()) + .unwrap_or_default(); + v.sort(); + v +} + +fn call(name: &str) -> ToolCall { + ToolCall { + id: format!("c-{name}"), + name: name.into(), + arguments: "{}".into(), + } +} + +/// The tool results by call name. +fn results(events: &[serde_json::Value]) -> Vec<(String, String, String)> { + let names: std::collections::HashMap = events + .iter() + .filter(|e| e["type"] == "tool_call") + .map(|e| { + ( + e["call"]["id"].as_str().unwrap_or_default().to_string(), + e["call"]["name"].as_str().unwrap_or_default().to_string(), + ) + }) + .collect(); + events + .iter() + .filter(|e| e["type"] == "tool_result") + .map(|e| { + let id = e["callId"] + .as_str() + .or_else(|| e["call_id"].as_str()) + .unwrap_or_default() + .to_string(); + ( + names.get(&id).cloned().unwrap_or(id), + e["status"].as_str().unwrap_or_default().to_string(), + e["content"].as_str().unwrap_or_default().to_string(), + ) + }) + .collect() +} + +/// Every host tool this fixture's sidecar runs with. +const HOST_TOOLS: &[&str] = &[ + "web_search", + "read_url", + SKILL_LOAD_TOOL, + "forge_test", + "slither_scan", + "aderyn_scan", + "medusa_fuzz", + "learn_propose", + "file_list", + "file_read", + "hello_capsule", +]; + +#[tokio::test(flavor = "multi_thread")] +async fn absent_allowlist_keeps_every_host_set() { + let fx = Fx::new(); + let (st, rec) = state(&fx, vec![]); + let (status, v) = open(&st, body(&fx, serde_json::json!({}))).await; + assert_eq!(status, StatusCode::CREATED, "{v}"); + assert!(v.get("toolSets").is_none(), "{v}"); + let id = v["id"].as_str().unwrap_or_default().to_string(); + run(&st, &id, "the deploy checklist for a contract").await; + let names = offered(&rec); + for t in HOST_TOOLS { + assert!(names.contains(&t.to_string()), "{t} missing from {names:?}"); + } + assert!(names.contains(&"node_status".to_string())); + // The per-turn skill index is in the prompt, as before. + let prompt = rec + .seen + .lock() + .map(|s| s[0].messages[0].content.clone()) + .unwrap_or_default(); + assert!(prompt.contains("deploy-checklist"), "{prompt}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn empty_allowlist_offers_no_host_tool_and_refuses_every_host_call() { + let fx = Fx::new(); + let calls = HOST_TOOLS.iter().map(|t| call(t)).collect::>(); + let (st, rec) = state(&fx, vec![AssistantTurn::tools(calls)]); + let (status, v) = open( + &st, + body( + &fx, + serde_json::json!({ "toolSets": [], "maxToolCallsPerStep": 16 }), + ), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{v}"); + assert_eq!(v["toolSets"], serde_json::json!([])); + let id = v["id"].as_str().unwrap_or_default().to_string(); + let events = run(&st, &id, "use every tool you have").await; + assert_eq!(offered(&rec), vec!["node_status".to_string()]); + let res = results(&events); + assert_eq!(res.len(), HOST_TOOLS.len(), "{res:?}"); + for (name, status, content) in &res { + assert_ne!(status, "ok", "{name} ran: {content}"); + assert!(content.contains("unknown tool"), "{name}: {content}"); + } + // Nothing ran in the sidecar: no host-side tool_call was announced. + assert!( + events + .iter() + .filter(|e| e["type"] == "tool_call") + .all(|e| e["host"].is_null()), + "{events:?}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn web_search_allowlist_offers_only_the_search_set() { + let fx = Fx::new(); + let (st, rec) = state( + &fx, + vec![AssistantTurn::tools(vec![ + call(SKILL_LOAD_TOOL), + call("forge_test"), + call("file_list"), + ])], + ); + let (status, v) = open( + &st, + body(&fx, serde_json::json!({ "toolSets": ["web_search"] })), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{v}"); + assert_eq!(v["toolSets"], serde_json::json!(["web_search"])); + let id = v["id"].as_str().unwrap_or_default().to_string(); + let events = run(&st, &id, "search the web").await; + // Retrieval may leave out core tools that do not match the request; of the host tools, + // exactly the search set is offered. + let host_offered: Vec = offered(&rec) + .into_iter() + .filter(|t| HOST_TOOLS.contains(&t.as_str())) + .collect(); + assert_eq!( + host_offered, + vec!["read_url".to_string(), "web_search".to_string()] + ); + for (name, status, content) in results(&events) { + assert_ne!(status, "ok", "{name} ran: {content}"); + assert!(content.contains("unknown tool"), "{name}: {content}"); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn each_allowed_set_brings_only_its_own_tools() { + let fx = Fx::new(); + let (st, rec) = state(&fx, vec![]); + let (status, v) = open( + &st, + body( + &fx, + serde_json::json!({ "toolSets": ["toolchain", "skills_read", "learn_propose", "capsules_run", "files"] }), + ), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{v}"); + let id = v["id"].as_str().unwrap_or_default().to_string(); + run(&st, &id, "hi").await; + let names = offered(&rec); + for t in ["web_search", "read_url"] { + assert!(!names.contains(&t.to_string()), "{t} offered: {names:?}"); + } + for t in [ + SKILL_LOAD_TOOL, + "forge_test", + "learn_propose", + "hello_capsule", + "file_list", + ] { + assert!(names.contains(&t.to_string()), "{t} missing: {names:?}"); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_unknown_set_name_refuses_the_session() { + let fx = Fx::new(); + let (st, _rec) = state(&fx, vec![]); + let (status, v) = open( + &st, + body(&fx, serde_json::json!({ "toolSets": ["search"] })), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!( + v["error"] + .as_str() + .unwrap_or_default() + .contains("unknown tool set 'search'"), + "{v}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn prompt_snapshot_false_keeps_the_prompt_exactly_as_sent() { + let fx = Fx::new(); + let (st, rec) = state(&fx, vec![]); + let (status, v) = open( + &st, + body( + &fx, + serde_json::json!({ "toolSets": ["skills_read"], "promptSnapshot": false }), + ), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{v}"); + assert_eq!(v["promptSnapshot"], serde_json::json!(false)); + let id = v["id"].as_str().unwrap_or_default().to_string(); + run(&st, &id, "the deploy checklist for a contract").await; + let seen = rec.seen.lock().map(|s| s.clone()).unwrap_or_default(); + assert_eq!(seen[0].messages[0].content, PROMPT); + assert!(seen[0].tools.iter().any(|t| t.name == SKILL_LOAD_TOOL)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn prompt_snapshot_false_refuses_a_persona() { + let fx = Fx::new(); + let (st, _rec) = state(&fx, vec![]); + let (status, v) = open( + &st, + body( + &fx, + serde_json::json!({ "promptSnapshot": false, "persona": "builder" }), + ), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{v}"); + assert!( + v["error"] + .as_str() + .unwrap_or_default() + .contains("promptSnapshot"), + "{v}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn status_reports_the_capability() { + let fx = Fx::new(); + let (st, _rec) = state(&fx, vec![]); + let r = app(st.clone()) + .oneshot(req("GET", "/status", serde_json::Value::Null)) + .await + .expect("status"); + assert_eq!(r.status(), StatusCode::OK); + let v = json(r).await; + assert_eq!( + v["capabilities"]["sessionToolSets"], + serde_json::json!(set_names()) + ); + assert_eq!( + v["capabilities"]["sessionPromptSnapshot"], + serde_json::json!(true) + ); +} diff --git a/agent-sidecar/src/lib.rs b/agent-sidecar/src/lib.rs index 9884ea3..3123725 100644 --- a/agent-sidecar/src/lib.rs +++ b/agent-sidecar/src/lib.rs @@ -32,6 +32,7 @@ pub mod files; pub mod git_identity; pub mod grants; pub mod hic_records; +pub mod host_sets; pub mod learn; pub mod llm_http; pub mod mcp_approvals; @@ -225,6 +226,18 @@ struct StatusBody { /// PBA-L6b-032: skills currently running. #[serde(rename = "runningSkills")] running_skills: usize, + /// Store daemons (citrate-core #356): what `POST /sessions` supports, so core can detect it. + capabilities: Capabilities, +} + +/// What this sidecar's session API supports (`GET /status` → `capabilities`). +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Capabilities { + /// The host tool-set names `toolSets` accepts (a per-session allowlist, deny by default). + session_tool_sets: Vec<&'static str>, + /// `promptSnapshot: false` is honoured (the sidecar adds nothing to core's system prompt). + session_prompt_snapshot: bool, } #[derive(Serialize)] @@ -424,6 +437,10 @@ async fn status( pending_approvals: st.queue.depth(), role_pending_approvals: st.queue.role_pending_depth(), running_skills: MAX_CONCURRENT_SKILLS.saturating_sub(st.run_slots.available_permits()), + capabilities: Capabilities { + session_tool_sets: host_sets::set_names(), + session_prompt_snapshot: true, + }, })) } @@ -854,6 +871,9 @@ async fn create_session( let session = st.sessions.get(&id); // HUP-S3.3: say what the persona did (offered skills, missing ones, pinned tools). let persona = session.as_ref().and_then(|s| s.persona().cloned()); + // Store daemons (citrate-core #356): echo the allowlist as applied. + let tool_sets = session.as_ref().and_then(|s| s.tool_sets()); + let prompt_snapshot = session.as_ref().map(|s| s.prompt_snapshot()); // HUP-S1.2: say how tokens are counted and tools ranked (probed once, off the // async runtime: the probes are blocking HTTP calls to the session's own endpoints). let retrieval = match session { @@ -866,6 +886,12 @@ async fn create_session( if let Some(p) = persona { body["persona"] = serde_json::json!(p); } + if let Some(t) = tool_sets { + body["toolSets"] = serde_json::json!(t); + } + if prompt_snapshot == Some(false) { + body["promptSnapshot"] = serde_json::json!(false); + } if let Some(r) = retrieval { body["tokenCounting"] = serde_json::json!(r.token_counting); body["retrieval"] = serde_json::json!(r.retrieval); @@ -2418,6 +2444,8 @@ mod deploy_guard_session_tests; mod e2e_build_tests; #[cfg(all(test, unix))] mod hgw_support; +#[cfg(test)] +mod host_sets_session_tests; #[cfg(all(test, unix))] mod loop_capability_tests; #[cfg(all(test, unix))] diff --git a/agent-sidecar/src/sessions.rs b/agent-sidecar/src/sessions.rs index 6c7dbbc..f9a516f 100644 --- a/agent-sidecar/src/sessions.rs +++ b/agent-sidecar/src/sessions.rs @@ -112,6 +112,7 @@ use crate::anchor::AnchorService; use crate::decide::DecideService; use crate::files::{FileTools, FileToolsHost}; use crate::grants::{FileToolHost, GrantSummary, SessionGrants}; +use crate::host_sets::{HostSet, HostSets}; use crate::metering::{MeteredLlm, MeteringStore, TeeSink}; use crate::sheets::SheetToolHost; use crate::shell_run::{ @@ -381,6 +382,22 @@ pub struct CreateSessionReq { /// none (build configuration stays the member's, as before). #[serde(default)] pub project_roots: Vec, + /// Store daemons (citrate-core #356): the sidecar-hosted tool sets this session may use, by + /// canonical name ([`crate::host_sets::HostSet::name`]). Present = ONLY these sets are offered + /// and callable (deny by default; `[]` = none); every other set's tools are absent and a call + /// to one is refused. An unknown name refuses the session. Absent = every set the sidecar runs + /// with, as before. + #[serde(default)] + pub tool_sets: Option>, + /// Store daemons (citrate-core #356): `false` = the sidecar adds nothing to the system prompt + /// core sent (no per-turn skill index, no persona fragment; a persona is then refused). + /// Absent or `true` = as before. + #[serde(default = "default_true")] + pub prompt_snapshot: bool, +} + +fn default_true() -> bool { + true } /// HUP-S3.3: what a session did with its persona (`POST /sessions` answers it as `persona`). @@ -580,6 +597,10 @@ pub struct Session { actions: Option>, /// HUP-S10.3: a scheduled run nobody watches; its taint is never cleared. unattended: bool, + /// Store daemons (citrate-core #356): the host tool sets this session may use. + host_sets: HostSets, + /// Store daemons: false when the sidecar adds nothing to core's system prompt. + prompt_snapshot: bool, /// Test seam: runs on the emitting thread right after an event becomes visible to readers, so /// a test can act as a core client at the exact moment the event appears. #[cfg(test)] @@ -633,6 +654,17 @@ impl Session { self.unattended } + /// Store daemons (citrate-core #356): the host tool sets this session was limited to, in + /// canonical order (`None` = no allowlist, every set the sidecar runs with). + pub fn tool_sets(&self) -> Option> { + self.host_sets.names() + } + + /// Store daemons: false when the session was opened with `promptSnapshot: false`. + pub fn prompt_snapshot(&self) -> bool { + self.prompt_snapshot + } + /// HGW-1: the action cards waiting for the member (`None` when the session has none, i.e. it /// was not opened by an HIC-aware app). pub fn action_pending(&self) -> Option> { @@ -1529,6 +1561,14 @@ impl SessionManager { if req.model.trim().is_empty() { return Err(SessionError::Invalid("model is required".into())); } + // Store daemons (citrate-core #356): the host tool sets this session may use. + let host_sets = + HostSets::from_allowlist(req.tool_sets.as_deref()).map_err(SessionError::Invalid)?; + if !req.prompt_snapshot && (req.persona.is_some() || req.custom_persona.is_some()) { + return Err(SessionError::Invalid( + "promptSnapshot: false keeps the system prompt exactly as sent, so it cannot carry a persona".into(), + )); + } let persona = citrate_agent_loop::personas::session_persona( req.persona.as_deref(), req.custom_persona.as_ref(), @@ -1538,7 +1578,11 @@ impl SessionManager { // allowlist with nothing installed offers no skills (and no `skill_load`), never others. // One snapshot of the (reloadable) library for the allowlist, the prompt section and // the session's `skill_load` host. - let base_skills = self.skills(); + let base_skills = if host_sets.allows(HostSet::SkillsRead) { + self.skills() + } else { + None + }; let allow = persona .as_ref() .filter(|p| p.restricts_skills()) @@ -1578,6 +1622,11 @@ impl SessionManager { }, }); let mut specs = req.tools; + // Every sidecar-hosted spec core passes is an installed capsule (the sidecar host sends + // any name it does not own to the capsule dispatch). + if !host_sets.allows(HostSet::CapsulesRun) { + specs.retain(|t| t.host != HostKind::Sidecar); + } let mut system_prompt = req.system_prompt; // L-23: the persona's prompt fragment is rendered here from the checked persona (a shipped // id or a custom persona that passed `CustomPersona::check`), never taken from app state. @@ -1600,16 +1649,20 @@ impl SessionManager { ))); } // US-3.2 AC1: each turn carries the at most SKILLS_PER_TURN skills that match it, - // ranked over the (persona-restricted) library; never the whole index. - turn_context.push(Arc::new(LiveSkillTurnIndex { - live: live.clone(), - k: SKILLS_PER_TURN, - ranker: retriever.clone(), - })); + // ranked over the (persona-restricted) library; never the whole index. Not with + // `promptSnapshot: false` (the prompt stays exactly as core sent it). + if req.prompt_snapshot { + turn_context.push(Arc::new(LiveSkillTurnIndex { + live: live.clone(), + k: SKILLS_PER_TURN, + ranker: retriever.clone(), + })); + } specs.push(skill_load_spec()); pinned_tools.push(SKILL_LOAD_TOOL.to_string()); } - if self.toolchain.is_some() { + let toolchain_on = self.toolchain.is_some() && host_sets.allows(HostSet::Toolchain); + if toolchain_on { if let Some(t) = specs.iter().find(|t| ToolchainHost::handles(&t.name)) { return Err(SessionError::Invalid(format!( "the tool name '{}' is reserved by the sidecar while the toolchain is enabled", @@ -1618,7 +1671,7 @@ impl SessionManager { } specs.extend(ToolchainHost::specs()); } - if self.search.is_some() { + if self.search.is_some() && host_sets.allows(HostSet::WebSearch) { if let Some(t) = specs.iter().find(|t| SearchHost::handles(&t.name)) { return Err(SessionError::Invalid(format!( "the tool name '{}' is reserved by the sidecar while search is enabled", @@ -1627,7 +1680,8 @@ impl SessionManager { } specs.extend(SearchHost::specs()); } - if self.files.is_some() { + let files_on = host_sets.allows(HostSet::Files); + if self.files.is_some() && files_on { if let Some(t) = specs.iter().find(|t| FileTools::handles(&t.name)) { return Err(SessionError::Invalid(format!( "the tool name '{}' is reserved by the sidecar while the file tools are enabled", @@ -1637,7 +1691,8 @@ impl SessionManager { specs.extend(FileTools::specs()); } let mut mcp_offered = None; - if let Some(mcp) = &self.mcp { + let mcp_on = self.mcp.is_some() && host_sets.allows(HostSet::Mcp); + if let Some(mcp) = self.mcp.as_ref().filter(|_| mcp_on) { if let Some(t) = specs.iter().find(|t| McpHost::reserved(&t.name)) { return Err(SessionError::Invalid(format!( "the tool name '{}' is in the 'mcp__' namespace, which is reserved while MCP servers are configured", @@ -1654,11 +1709,11 @@ impl SessionManager { specs.extend(offered); } // HUP-S4.1: MCP approval cards only for a client that shows `hic: required` to a person. - let mcp_approvals = (self.mcp.is_some() && req.hic_aware) + let mcp_approvals = (mcp_on && req.hic_aware) .then(|| Arc::new(crate::mcp_approvals::McpApprovals::default())); // HGW-1: action cards only for a client that shows `hic: required` to a person. let actions = req.hic_aware.then(|| Arc::new(ActionApprovals::default())); - if self.browser.is_some() { + if self.browser.is_some() && host_sets.allows(HostSet::WebBrowse) { if let Some(t) = specs.iter().find(|t| browser_tools::handles(&t.name)) { return Err(SessionError::Invalid(format!( "the tool name '{}' is reserved by the sidecar while the browser is enabled", @@ -1667,7 +1722,7 @@ impl SessionManager { } specs.extend(browser_tools::specs()); } - if self.learn.is_some() { + if self.learn.is_some() && host_sets.allows(HostSet::LearnPropose) { if specs .iter() .any(|t| t.name == crate::learn::LEARN_PROPOSE_TOOL) @@ -1680,6 +1735,7 @@ impl SessionManager { specs.push(crate::learn::learn_propose_spec()); } if self.projects.is_some() + && files_on && specs .iter() .any(|t| t.name == crate::projects::PROJECT_CREATE_TOOL) @@ -1689,7 +1745,8 @@ impl SessionManager { crate::projects::PROJECT_CREATE_TOOL ))); } - if self.shell_run.is_some() && specs.iter().any(|t| t.name == SHELL_RUN_TOOL) { + let shell_on = self.shell_run.is_some() && host_sets.allows(HostSet::ShellRun); + if shell_on && specs.iter().any(|t| t.name == SHELL_RUN_TOOL) { return Err(SessionError::Invalid(format!( "the tool name '{SHELL_RUN_TOOL}' is reserved by the sidecar while shell_run is on" ))); @@ -1707,10 +1764,9 @@ impl SessionManager { "folder grants need the member's home directory, which the sidecar does not know".into(), ) })?; - if let Some(t) = specs - .iter() - .find(|t| crate::grants::handles(&t.name) || crate::sheets::handles(&t.name)) - { + if let Some(t) = specs.iter().find(|t| { + files_on && (crate::grants::handles(&t.name) || crate::sheets::handles(&t.name)) + }) { return Err(SessionError::Invalid(format!( "the tool name '{}' is reserved by the sidecar while folder grants are given", t.name @@ -1718,7 +1774,7 @@ impl SessionManager { } // HUP-S2.9: with an undo store, a grant session also gets the checkpointed fs_* // tools, checked against this same grant document. - let fs_on_grants = self.checkpoints.is_some() && self.files.is_none(); + let fs_on_grants = files_on && self.checkpoints.is_some() && self.files.is_none(); if fs_on_grants { if let Some(t) = specs.iter().find(|t| FileTools::handles(&t.name)) { return Err(SessionError::Invalid(format!( @@ -1738,18 +1794,20 @@ impl SessionManager { SessionError::Invalid(format!("projectRoots was refused: {e}")) })?; } - specs.extend(crate::grants::file_tool_specs()); - specs.extend(crate::sheets::sheet_tool_specs()); + if files_on { + specs.extend(crate::grants::file_tool_specs()); + specs.extend(crate::sheets::sheet_tool_specs()); + } if fs_on_grants { specs.extend(FileTools::specs()); } Some(Arc::new(g)) } }; - if self.projects.is_some() && grants.is_some() { + if self.projects.is_some() && files_on && grants.is_some() { specs.push(crate::projects::project_create_spec()); } - let shell = match (&self.shell_run, &grants) { + let shell = match (self.shell_run.as_ref().filter(|_| shell_on), &grants) { (Some(cfg), Some(g)) => { specs.push(shell_run_spec()); Some(Arc::new( @@ -1783,11 +1841,12 @@ impl SessionManager { pinned_tools: pinned, } }); - let toolchain: Option> = match (&self.toolchain, &grants) { - (Some(t), Some(g)) => Some(t.scoped_to(g.clone()).map_err(SessionError::Invalid)?), - (Some(t), None) => Some(t.clone() as Arc), - (None, _) => None, - }; + let toolchain: Option> = + match (self.toolchain.as_ref().filter(|_| toolchain_on), &grants) { + (Some(t), Some(g)) => Some(t.scoped_to(g.clone()).map_err(SessionError::Invalid)?), + (Some(t), None) => Some(t.clone() as Arc), + (None, _) => None, + }; // HUP-S6.3 → S6.4: keep each run's raw report for core's deploy gate; the model sees the // result without it. let toolchain_reports = toolchain @@ -1802,6 +1861,7 @@ impl SessionManager { // HUP-S2.9: a grant session's fs_* tools check the session's grant document (core's // grant store), not a grants file. let files = match (&grants, &self.checkpoints) { + _ if !files_on => None, (Some(g), Some(store)) => Some(Arc::new(FileTools::new( store.clone(), crate::files::GrantSource::Session(g.clone()), @@ -1935,6 +1995,8 @@ impl SessionManager { mcp_approvals, actions, unattended: req.unattended, + host_sets, + prompt_snapshot: req.prompt_snapshot, #[cfg(test)] event_hook: Mutex::new(None), }); @@ -2019,10 +2081,13 @@ impl SessionManager { registry = registry.with_policy(Arc::new(guard)); } let skill_host = session.skills.as_ref().map(|l| SkillHost::new(l.current())); - let capsule_host = capsules.map(|d| CapsuleHost { - dispatch: d, - sandbox: session.capsule_sandbox.clone(), - }); + let sets = session.host_sets; + let capsule_host = capsules + .filter(|_| sets.allows(HostSet::CapsulesRun)) + .map(|d| CapsuleHost { + dispatch: d, + sandbox: session.capsule_sandbox.clone(), + }); let toolchain = session.toolchain.clone(); let files = session .files @@ -2034,44 +2099,64 @@ impl SessionManager { .checkpoints .clone() .and_then(|st| crate::files::UndoScope::new(st, &session.id)); - let file_host = session.grants.clone().map(|g| { - let h = FileToolHost::new(g); - match &undo { - Some(u) => h.with_undo(u.clone()), - None => h, - } - }); - let sheet_host = session.grants.clone().map(|g| { - let h = SheetToolHost::new(g); - match &undo { - Some(u) => h.with_undo(u.clone()), - None => h, - } - }); - let search = self.search.clone(); - let mcp_host = self.mcp.clone().map(|h| { - let mut t = McpToolHost::new(h, session.stop.clone()); - if let Some(o) = &session.mcp_offered { - t = t.with_offered(o.clone()); - } - if let Some(a) = &session.mcp_approvals { - t = t.with_approver(a.clone()); - } - t - }); + let file_host = session + .grants + .clone() + .filter(|_| sets.allows(HostSet::Files)) + .map(|g| { + let h = FileToolHost::new(g); + match &undo { + Some(u) => h.with_undo(u.clone()), + None => h, + } + }); + let sheet_host = session + .grants + .clone() + .filter(|_| sets.allows(HostSet::Files)) + .map(|g| { + let h = SheetToolHost::new(g); + match &undo { + Some(u) => h.with_undo(u.clone()), + None => h, + } + }); + let search = self + .search + .clone() + .filter(|_| sets.allows(HostSet::WebSearch)); + let mcp_host = self + .mcp + .clone() + .filter(|_| sets.allows(HostSet::Mcp)) + .map(|h| { + let mut t = McpToolHost::new(h, session.stop.clone()); + if let Some(o) = &session.mcp_offered { + t = t.with_offered(o.clone()); + } + if let Some(a) = &session.mcp_approvals { + t = t.with_approver(a.clone()); + } + t + }); let learn_host = self .learn .clone() + .filter(|_| sets.allows(HostSet::LearnPropose)) .map(|svc| crate::learn::LearnToolHost::new(svc, session.clone())); - let browser_host = self.browser.clone().map(|b| { - BrowserToolHost::new(b, session.stop.clone()).with_picker(Arc::new( - crate::decide::SessionPicker::new( - self.decide.clone(), - session.decide_llm.clone(), - &session.cfg.model, - ), - )) - }); + let browser_host = self + .browser + .clone() + .filter(|_| sets.allows(HostSet::WebBrowse)) + .map(|b| { + BrowserToolHost::new(b, session.stop.clone()).with_picker(Arc::new( + crate::decide::SessionPicker::new( + self.decide.clone(), + session.decide_llm.clone(), + &session.cfg.model, + ), + )) + }); let shell_host = session.shell.as_ref().map(|s| { s.host( session.taint.clone(), @@ -2080,13 +2165,15 @@ impl SessionManager { ) }); let projects_host = match (&self.projects, &session.grants) { - (Some(cfg), Some(g)) => Some(crate::projects::ProjectCreateHost::new( - cfg.clone(), - g.clone(), - session.actions.clone(), - session.taint.clone(), - session.stop.clone(), - )), + (Some(cfg), Some(g)) if sets.allows(HostSet::Files) => { + Some(crate::projects::ProjectCreateHost::new( + cfg.clone(), + g.clone(), + session.actions.clone(), + session.taint.clone(), + session.stop.clone(), + )) + } _ => None, }; if file_host.is_some() From a489a618b143985d16f2a2a051d35e27a34adec5 Mon Sep 17 00:00:00 2001 From: Larry Klosowski Date: Thu, 22 Oct 2026 12:47:19 -0700 Subject: [PATCH 2/4] test(sidecar): cover the execute-time host-set check With capsules_run allowed, a core-passed sidecar spec named like a host tool (web_search, read_url, learn_propose, file_list, file_read, sheet_read) passes the offer-time filter as a capsule; only the registry's execute-time check keeps it from the disallowed set's host. No test failed with that check removed. Red by mutation: removing the WebSearch, LearnPropose or Files execute-time filter, or all of them, fails this test and no other. Co-Authored-By: Claude Opus 5.5 (1M context) --- agent-sidecar/src/host_sets_session_tests.rs | 43 ++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/agent-sidecar/src/host_sets_session_tests.rs b/agent-sidecar/src/host_sets_session_tests.rs index 21f412e..3d9a4d2 100644 --- a/agent-sidecar/src/host_sets_session_tests.rs +++ b/agent-sidecar/src/host_sets_session_tests.rs @@ -471,3 +471,46 @@ async fn status_reports_the_capability() { serde_json::json!(true) ); } + +/// What a sidecar-hosted call that no set host takes answers (no capsule dispatch here). +const CAPSULE_FALLTHROUGH: &str = "is not available in this session"; + +/// The second, execute-time check (DGX non-blocking, fed#331). With `capsules_run` allowed, core +/// can pass a sidecar-hosted spec under a host tool's name (`web_search`, `read_url`, +/// `learn_propose`, `file_list`, `file_read`, `sheet_read`): it is kept as a capsule spec, so it passes the +/// offer-time filter. At execute time the session's registry must not hold the hosts of the sets +/// it was not allowed, so these calls never reach search, learning, the folder-grant file tools +/// or the sheet tools. +#[tokio::test(flavor = "multi_thread")] +async fn a_capsule_spec_named_like_a_host_tool_never_reaches_a_disallowed_set() { + // One session per name, so one call's taint (web_search marks the session) never decides + // another call's result. + for name in [ + "web_search", + "read_url", + "learn_propose", + "file_list", + "file_read", + "sheet_read", + ] { + let fx = Fx::new(); + let (st, _rec) = state(&fx, vec![AssistantTurn::tools(vec![call(name)])]); + let mut b = body(&fx, serde_json::json!({ "toolSets": ["capsules_run"] })); + b["tools"].as_array_mut().expect("tools").push(serde_json::json!({ + "name": name, "description": "a capsule", "parameters": {"type": "object"}, "host": "sidecar" + })); + let (status, v) = open(&st, b).await; + assert_eq!(status, StatusCode::CREATED, "{name}: {v}"); + assert_eq!(v["toolSets"], serde_json::json!(["capsules_run"])); + let id = v["id"].as_str().unwrap_or_default().to_string(); + let events = run(&st, &id, "use it").await; + let res = results(&events); + assert_eq!(res.len(), 1, "{name}: {res:?}"); + let (_, status, content) = &res[0]; + assert_ne!(status, "ok", "{name} ran in a disallowed set: {content}"); + assert!( + content.contains(CAPSULE_FALLTHROUGH), + "{name} reached a set host: {content}" + ); + } +} From 177b8af31d73b7478be02c758fcda209fdb1b3e0 Mon Sep 17 00:00:00 2001 From: Larry Klosowski Date: Fri, 23 Oct 2026 08:09:49 -0700 Subject: [PATCH 3/4] test(sidecar): write/exec sets gated; the session body fails closed (red) - A fixture with shell_run, a checkpoint store and projects: the create-time gates for shell_run, the checkpointed fs_* tools and project_create are each pinned (offered list plus refused call), and capsule specs named like them reach no write host. - An explicit toolSets: null must mean no tool sets, and a key the sidecar does not know must refuse the session. These two fail on assertions at a489a61. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0169vzH5cr7QFnL5RJ7tZkPj --- agent-sidecar/src/host_sets_session_tests.rs | 246 +++++++++++++++++++ 1 file changed, 246 insertions(+) diff --git a/agent-sidecar/src/host_sets_session_tests.rs b/agent-sidecar/src/host_sets_session_tests.rs index 3d9a4d2..37a7127 100644 --- a/agent-sidecar/src/host_sets_session_tests.rs +++ b/agent-sidecar/src/host_sets_session_tests.rs @@ -514,3 +514,249 @@ async fn a_capsule_spec_named_like_a_host_tool_never_reaches_a_disallowed_set() ); } } + +// ---- The write and exec sets: shell_run, the checkpointed fs_* tools, project_create ---------- + +/// The manager of [`state`] plus `shell_run` (sandbox off: only the offer and refusal paths run +/// here), a checkpoint store (the grant session's fs_* tools) and projects. +fn write_state(fx: &Fx, turns: Vec) -> (Arc, Arc) { + std::fs::create_dir_all(fx.p("home/projects")).expect("projects root"); + let rec = Arc::new(Recorder { + turns: Mutex::new(turns), + seen: Mutex::new(vec![]), + }); + let r2 = rec.clone(); + let store = Arc::new( + citrate_agent_checkpoints::CheckpointStore::open( + &fx.p("ckpt"), + citrate_agent_checkpoints::Config::default(), + ) + .expect("checkpoint store"), + ); + let shell = crate::shell_run::ShellRunConfig { + search_path: vec!["/bin".into(), "/usr/bin".into()], + sandbox: citrate_agent_shell::sandbox::SandboxPolicy::new(SandboxMode::Off), + approval_timeout: Duration::from_secs(5), + auto_approve: None, + commit_identity: crate::shell_run::GitIdentity::hermes(), + }; + let mgr = sessions::SessionManager::new( + Arc::new(move |_ep: &sessions::LlmEndpoint| r2.clone() as Arc), + Duration::from_secs(5), + ) + .with_grants_home(fx.p("home")) + .with_checkpoints(store) + .with_shell_run(Arc::new(shell)) + .with_projects(Arc::new(crate::projects::ProjectsConfig { + root: fx.p("home/projects"), + renderer: None, + })); + let st = Arc::new(AppState { + estop: EmergencyStop::new(), + queue: Arc::new(ApprovalQueue::new()), + skills: vec![], + dispatch: None, + bearer: BEARER.to_string(), + run_slots: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_SKILLS)), + sessions: Arc::new(mgr), + }); + (st, rec) +} + +const SHELL: &str = "shell_run"; +const FS_TOOLS: &[&str] = &["fs_write", "fs_edit", "fs_delete", "fs_rename"]; +const PROJECT: &str = "project_create"; + +/// What a session with `toolSets` (`None` = absent) offers of the write and exec tools, and what +/// a call to each one returns. +async fn write_tools_for( + sets: Option, +) -> (Vec, Vec<(String, String, String)>) { + let fx = Fx::new(); + let mut names = vec![SHELL, PROJECT]; + names.extend_from_slice(FS_TOOLS); + let (st, rec) = write_state( + &fx, + vec![AssistantTurn::tools( + names.iter().map(|n| call(n)).collect(), + )], + ); + let extra = match sets { + Some(s) => { + serde_json::json!({ "toolSets": s, "maxToolCallsPerStep": 16, "maxToolsPerRequest": 64 }) + } + None => serde_json::json!({ "maxToolCallsPerStep": 16, "maxToolsPerRequest": 64 }), + }; + let (status, v) = open(&st, body(&fx, extra)).await; + assert_eq!(status, StatusCode::CREATED, "{v}"); + let id = v["id"].as_str().unwrap_or_default().to_string(); + let events = run(&st, &id, "write a file, make a project and run a command").await; + (offered(&rec), results(&events)) +} + +fn refused(res: &[(String, String, String)], name: &str) -> bool { + res.iter() + .any(|(n, status, content)| n == name && status != "ok" && content.contains("unknown tool")) +} + +#[tokio::test(flavor = "multi_thread")] +async fn without_an_allowlist_the_fixture_offers_every_write_and_exec_tool() { + let (offered, _) = write_tools_for(None).await; + for t in [SHELL, PROJECT].iter().chain(FS_TOOLS) { + assert!( + offered.contains(&t.to_string()), + "{t} missing from {offered:?}" + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_empty_allowlist_offers_no_write_or_exec_tool() { + let (offered, res) = write_tools_for(Some(serde_json::json!([]))).await; + for t in [SHELL, PROJECT].iter().chain(FS_TOOLS) { + assert!( + !offered.contains(&t.to_string()), + "{t} offered: {offered:?}" + ); + assert!(refused(&res, t), "{t} not refused: {res:?}"); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn files_allows_fs_tools_and_project_create_but_not_shell_run() { + let (offered, res) = write_tools_for(Some(serde_json::json!(["files"]))).await; + for t in [PROJECT].iter().chain(FS_TOOLS) { + assert!( + offered.contains(&t.to_string()), + "{t} missing from {offered:?}" + ); + } + assert!( + !offered.contains(&SHELL.to_string()), + "shell_run offered: {offered:?}" + ); + assert!(refused(&res, SHELL), "shell_run not refused: {res:?}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn web_search_leaves_out_fs_tools_project_create_and_shell_run() { + let (offered, res) = write_tools_for(Some(serde_json::json!(["web_search"]))).await; + for t in [SHELL, PROJECT].iter().chain(FS_TOOLS) { + assert!( + !offered.contains(&t.to_string()), + "{t} offered: {offered:?}" + ); + assert!(refused(&res, t), "{t} not refused: {res:?}"); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn shell_run_allows_shell_run_but_not_fs_tools_or_project_create() { + let (offered, res) = write_tools_for(Some(serde_json::json!(["shell_run"]))).await; + assert!( + offered.contains(&SHELL.to_string()), + "shell_run missing from {offered:?}" + ); + for t in [PROJECT].iter().chain(FS_TOOLS) { + assert!( + !offered.contains(&t.to_string()), + "{t} offered: {offered:?}" + ); + assert!(refused(&res, t), "{t} not refused: {res:?}"); + } +} + +/// The execution layer for the write sets: with only `capsules_run`, core-passed capsule specs +/// named like the fs_* tools and `project_create` reach no file or project host. +#[tokio::test(flavor = "multi_thread")] +async fn capsule_specs_named_like_write_tools_reach_no_write_host() { + for name in ["fs_write", "fs_delete", PROJECT] { + let fx = Fx::new(); + let (st, _rec) = write_state(&fx, vec![AssistantTurn::tools(vec![call(name)])]); + let mut b = body(&fx, serde_json::json!({ "toolSets": ["capsules_run"] })); + b["tools"].as_array_mut().expect("tools").push(serde_json::json!({ + "name": name, "description": "a capsule", "parameters": {"type": "object"}, "host": "sidecar" + })); + let (status, v) = open(&st, b).await; + assert_eq!(status, StatusCode::CREATED, "{name}: {v}"); + let id = v["id"].as_str().unwrap_or_default().to_string(); + let res = results(&run(&st, &id, "use it").await); + assert_eq!(res.len(), 1, "{name}: {res:?}"); + assert_ne!(res[0].1, "ok", "{name} ran: {}", res[0].2); + assert!( + res[0].2.contains(CAPSULE_FALLTHROUGH), + "{name} reached a write host: {}", + res[0].2 + ); + } +} + +// ---- The session body fails closed -------------------------------------------------------------- + +/// An explicit `toolSets: null` is no tool sets, never all of them. +#[tokio::test(flavor = "multi_thread")] +async fn a_null_allowlist_is_no_tool_sets() { + let fx = Fx::new(); + let (st, rec) = state( + &fx, + vec![AssistantTurn::tools( + HOST_TOOLS.iter().map(|t| call(t)).collect(), + )], + ); + let (status, v) = open( + &st, + body( + &fx, + serde_json::json!({ "toolSets": null, "maxToolCallsPerStep": 16 }), + ), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{v}"); + assert_eq!( + v["toolSets"], + serde_json::json!([]), + "the applied allowlist is echoed: none" + ); + let id = v["id"].as_str().unwrap_or_default().to_string(); + let events = run(&st, &id, "use every tool you have").await; + assert_eq!(offered(&rec), vec!["node_status".to_string()]); + for (name, status, content) in results(&events) { + assert_ne!(status, "ok", "{name} ran: {content}"); + } +} + +/// A key the sidecar does not know (a misspelled `toolSets`, say) refuses the session: it is +/// never opened with the sets the misspelling meant to restrict. +#[tokio::test(flavor = "multi_thread")] +async fn an_unknown_key_refuses_the_session() { + for extra in [ + serde_json::json!({ "toolsets": [] }), + serde_json::json!({ "tool_sets": [] }), + serde_json::json!({ "toolSet": ["web_search"] }), + serde_json::json!({ "promptsnapshot": false }), + ] { + let fx = Fx::new(); + let (st, _rec) = state(&fx, vec![]); + let (status, v) = open(&st, body(&fx, extra.clone())).await; + assert_ne!(status, StatusCode::CREATED, "{extra}: {v}"); + assert!(status.is_client_error(), "{extra}: {status}"); + assert_eq!(st.sessions.count(), 0, "{extra}: no session was opened"); + } +} + +/// Every key core and the other clients send is still accepted. +#[tokio::test(flavor = "multi_thread")] +async fn every_known_key_is_accepted() { + let fx = Fx::new(); + let (st, _rec) = state(&fx, vec![]); + let b = body( + &fx, + serde_json::json!({ + "maxSteps": 8, "maxTokens": 512, "maxToolCallsPerStep": 4, "maxToolsPerRequest": 16, + "contextTokens": 8192, "hicAware": true, "unattended": true, "projectRoots": [], + "toolSets": ["web_search"], "promptSnapshot": false + }), + ); + let (status, v) = open(&st, b).await; + assert_eq!(status, StatusCode::CREATED, "{v}"); +} From 33a54eb2b7ca1faac249f74f1d93f2666ac059bd Mon Sep 17 00:00:00 2001 From: Larry Klosowski Date: Fri, 23 Oct 2026 08:17:03 -0700 Subject: [PATCH 4/4] fix(sidecar): sessions: unknown keys are refused; a null tool-set list is none POST /sessions now refuses a body with a key the sidecar does not know (deny_unknown_fields), so a misspelled toolSets can never open a session with every host tool set. An explicit "toolSets": null is read as [] (no sets) and echoed as such; only an absent key leaves a session unrestricted, as before. Core's echo check stays as a second layer. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0169vzH5cr7QFnL5RJ7tZkPj --- agent-sidecar/src/sessions.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/agent-sidecar/src/sessions.rs b/agent-sidecar/src/sessions.rs index f9a516f..695a4ee 100644 --- a/agent-sidecar/src/sessions.rs +++ b/agent-sidecar/src/sessions.rs @@ -328,9 +328,10 @@ pub struct RetrievalReport { pub max_tool_schemas: usize, } -/// `POST /sessions` body. +/// `POST /sessions` body. Fails closed: a key this sidecar does not know refuses the session +/// (a misspelled `toolSets` must never open one with every host tool set). #[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct CreateSessionReq { pub model: String, pub system_prompt: String, @@ -386,8 +387,8 @@ pub struct CreateSessionReq { /// canonical name ([`crate::host_sets::HostSet::name`]). Present = ONLY these sets are offered /// and callable (deny by default; `[]` = none); every other set's tools are absent and a call /// to one is refused. An unknown name refuses the session. Absent = every set the sidecar runs - /// with, as before. - #[serde(default)] + /// with, as before; an explicit `null` = no sets (never all of them). + #[serde(default, deserialize_with = "null_is_no_sets")] pub tool_sets: Option>, /// Store daemons (citrate-core #356): `false` = the sidecar adds nothing to the system prompt /// core sent (no per-turn skill index, no persona fragment; a persona is then refused). @@ -400,6 +401,17 @@ fn default_true() -> bool { true } +/// `toolSets` when the key is present: an array of names, or `null`, which is read as `[]` (no +/// sets). Only an absent key leaves the session unrestricted. +fn null_is_no_sets<'de, D>(d: D) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + Ok(Some( + Option::>::deserialize(d)?.unwrap_or_default(), + )) +} + /// HUP-S3.3: what a session did with its persona (`POST /sessions` answers it as `persona`). #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct PersonaReport {