diff --git a/agent-sidecar/src/lib.rs b/agent-sidecar/src/lib.rs index ddecf0a..8bda1fb 100644 --- a/agent-sidecar/src/lib.rs +++ b/agent-sidecar/src/lib.rs @@ -35,6 +35,7 @@ pub mod llm_http; pub mod mcp_approvals; pub mod mcp_probe; pub mod metering; +pub mod projects; pub mod resources; pub mod retrieval_http; pub mod search; @@ -1859,6 +1860,25 @@ pub fn production_sessions_with( let mgr = mgr.with_self_review(self_review_from_env_var( std::env::var("CITRATE_HERMES_SELF_REVIEW").ok().as_deref(), )); + // HGW-3: project_create (off by default; every project needs the member's approval). + let mgr = match projects::ProjectsConfig::from_env_vars( + |k| std::env::var(k).ok(), + projects::production_render_sandbox(), + ) { + Ok(Some(cfg)) => { + eprintln!( + "citrate-agent-sidecar: project_create on for sessions with folder grants; projects go in {}{}", + cfg.root.display(), + if cfg.renderer.is_some() { ", with templates" } else { "" } + ); + mgr.with_projects(Arc::new(cfg)) + } + Ok(None) => mgr, + Err(e) => { + eprintln!("citrate-agent-sidecar: project_create stays off: {e}"); + mgr + } + }; // US-2.2 AC2: shell_run (off by default; always inside the OS sandbox). let mgr = match shell_run::ShellRunConfig::from_env() { Some(cfg) => { @@ -2364,6 +2384,8 @@ mod deploy_guard_session_tests; mod hgw_support; #[cfg(all(test, unix))] mod project_policy_tests; +#[cfg(all(test, unix))] +mod projects_tests; #[cfg(test)] mod search_session_tests; #[cfg(test)] diff --git a/agent-sidecar/src/main.rs b/agent-sidecar/src/main.rs index 1c2872b..8241660 100644 --- a/agent-sidecar/src/main.rs +++ b/agent-sidecar/src/main.rs @@ -30,6 +30,13 @@ //! approves, run in the OS sandbox, never without it) in every //! session opened with folder grants; anything else = off (optional) //! CITRATE_HERMES_SHELL_PATH shell_run search path override, a path list (optional) +//! CITRATE_HERMES_PROJECTS HGW-3: `1` offers project_create (a new project folder, after the +//! member approves it) in sessions opened with folder grants (optional) +//! CITRATE_HERMES_PROJECTS_ROOT absolute folder new projects go in; default ~/Citrate/Projects +//! CITRATE_HERMES_TEMPLATE_RENDERER / CITRATE_HERMES_TEMPLATES_ROOT absolute paths of core's +//! citrate-templates program and the bundled templates folder; both +//! set = project_create can start from a template (optional) +//! CITRATE_HERMES_TEMPLATE_TIER T0 (default), T1 or T2: the tier passed to the renderer //! CITRATE_HERMES_MCP HUP-S4.1: path to the MCP server allowlist (TOML, or JSON by //! `.json` extension); unset = no MCP (optional) //! CITRATE_HERMES_MCP_REGISTRY HUP-S4.4: core's owner-only saved MCP server list diff --git a/agent-sidecar/src/projects.rs b/agent-sidecar/src/projects.rs new file mode 100644 index 0000000..bb4ea25 --- /dev/null +++ b/agent-sidecar/src/projects.rs @@ -0,0 +1,738 @@ +//! HGW-3 — `project_create`: Hermes makes a new project folder, with the member's approval. +//! +//! One call `{name, template?, params?}`: +//! +//! 1. **Checked before anything is asked.** `name` is one plain folder name (letters, digits, +//! `.`, `_`, `-`; starts with a letter or digit; at most [`MAX_NAME_LEN`] characters; never +//! `..`, a path separator or `~`). The projects root (`CITRATE_HERMES_PROJECTS_ROOT`, which +//! citrate-core passes; default `~/Citrate/Projects`) must exist and be covered by the +//! session's live read and write folder grants over its whole subtree (citrate-core's +//! workspace card grants it). `/` must not exist yet. A template id, when given, +//! is a plain id and needs a configured renderer. +//! 2. **HIC, always.** The call waits on the session's approval card +//! (`/sessions/:id/approvals` routes, [`crate::action_approvals`]) showing the exact folder, +//! template and parameters. Only an +//! explicit allow creates anything. A session not opened by an HIC-aware app cannot ask, so it +//! creates nothing. In a tainted session the one card carries the taint reason too. +//! 3. **Creates** `/` (the grants are checked again first), renders the template into +//! it when one was asked for ([`TemplateRenderer`]; production runs citrate-core's +//! `citrate-templates render` program in the OS sandbox, writing only the new folder), writes +//! the project marker `.citrate/project.json` ([`ProjectMarker`]), and makes the folder one of +//! the session's project roots, so the file tools may write its build configuration (HGW-2). +//! A failed render removes the new folder again and reports why. +//! 4. **Returns** the absolute path (`{"ok": true, "path": …, "name": …, "template": …, +//! "marker": …, "rendered": …}`). citrate-core keeps the path and sends it back in +//! `projectRoots` when it opens later sessions. +//! +//! The marker is a record for people and for citrate-core; it does not make a folder a project +//! by itself (a cloned repository could carry one). Nothing here signs or holds a key (Rule 3). + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use serde::Serialize; + +use crate::action_approvals::{ActionApprovals, ActionPending}; +use crate::grants::SessionGrants; +use citrate_agent_loop::{ + Effect, HostKind, StopFlag, TaintState, ToolAnnotations, ToolCall, ToolHost, ToolOutcome, + ToolSpec, Trust, +}; +use citrate_agent_shell::sandbox::{SandboxMode, SandboxPolicy}; +use citrate_agent_shell::{Allowlist, ArgPolicy, RunRequest, ShellPolicy, ShellRunner}; + +pub const PROJECT_CREATE_TOOL: &str = "project_create"; +/// `1` offers `project_create` to sessions opened with folder grants; anything else leaves it off. +pub const PROJECTS_ENV: &str = "CITRATE_HERMES_PROJECTS"; +/// The folder new projects go in (absolute). Unset = `$HOME/Citrate/Projects`. +pub const PROJECTS_ROOT_ENV: &str = "CITRATE_HERMES_PROJECTS_ROOT"; +/// Absolute path of citrate-core's `citrate-templates` program. Unset = no templates. +pub const TEMPLATE_RENDERER_ENV: &str = "CITRATE_HERMES_TEMPLATE_RENDERER"; +/// Absolute path of the bundled templates folder (`templates/` in the app's resources). +pub const TEMPLATES_ROOT_ENV: &str = "CITRATE_HERMES_TEMPLATES_ROOT"; +/// The hardware tier passed to the renderer (`T0`, `T1` or `T2`). Unset = `T0`. +pub const TEMPLATE_TIER_ENV: &str = "CITRATE_HERMES_TEMPLATE_TIER"; +/// The default projects root, under the member's home. +pub const DEFAULT_PROJECTS_DIR: &str = "Citrate/Projects"; +/// The marker, relative to the project folder. +pub const MARKER_DIR: &str = ".citrate"; +pub const MARKER_FILE: &str = "project.json"; +/// Longest project name. +pub const MAX_NAME_LEN: usize = 64; +/// Most template parameters, and the longest key and value. +pub const MAX_PARAMS: usize = 16; +pub const MAX_PARAM_LEN: usize = 256; +/// How long a render may take. +pub const RENDER_TIMEOUT: Duration = Duration::from_secs(60); + +/// What `project_create` needs. +#[derive(Clone)] +pub struct ProjectsConfig { + /// Where new projects go (absolute). + pub root: PathBuf, + /// Renders a bundled template into a new folder; `None` = templates are refused. + pub renderer: Option>, +} + +impl std::fmt::Debug for ProjectsConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ProjectsConfig") + .field("root", &self.root) + .field("renderer", &self.renderer.is_some()) + .finish() + } +} + +impl ProjectsConfig { + /// `None` unless `CITRATE_HERMES_PROJECTS` is exactly `1` and a projects root is known + /// (`CITRATE_HERMES_PROJECTS_ROOT`, absolute, or `$HOME/Citrate/Projects`). Templates need + /// both `CITRATE_HERMES_TEMPLATE_RENDERER` and `CITRATE_HERMES_TEMPLATES_ROOT` (absolute); + /// `sandbox` is how the renderer runs (production: the OS sandbox when there is one). + pub fn from_env_vars( + get: impl Fn(&str) -> Option, + sandbox: SandboxPolicy, + ) -> Result, String> { + if get(PROJECTS_ENV).as_deref() != Some("1") { + return Ok(None); + } + let root = match get(PROJECTS_ROOT_ENV).filter(|v| !v.trim().is_empty()) { + Some(r) => { + let p = PathBuf::from(r); + if !p.is_absolute() { + return Err(format!("{PROJECTS_ROOT_ENV} must be an absolute path")); + } + p + } + None => match get("HOME").filter(|h| !h.is_empty()) { + Some(h) => PathBuf::from(h).join(DEFAULT_PROJECTS_DIR), + None => return Err("no home directory is known for the projects root".into()), + }, + }; + let renderer = match ( + get(TEMPLATE_RENDERER_ENV).filter(|v| !v.is_empty()), + get(TEMPLATES_ROOT_ENV).filter(|v| !v.is_empty()), + ) { + (None, None) => None, + (Some(bin), Some(templates)) => { + let tier = get(TEMPLATE_TIER_ENV).unwrap_or_else(|| "T0".into()); + Some(Arc::new(CliTemplateRenderer::new( + PathBuf::from(bin), + PathBuf::from(templates), + &tier, + sandbox, + )?) as Arc) + } + _ => { + return Err(format!( + "templates need both {TEMPLATE_RENDERER_ENV} and {TEMPLATES_ROOT_ENV}" + )) + } + }; + Ok(Some(ProjectsConfig { root, renderer })) + } +} + +/// HGW-3: renders one bundled template into an empty folder. The interface citrate-core's +/// renderer is called through: `template` is the template id, `params` its parameters, `dest` +/// the new, empty project folder. Returns the renderer's own report (JSON), shown to the model. +pub trait TemplateRenderer: Send + Sync { + fn render( + &self, + template: &str, + params: &BTreeMap, + dest: &Path, + ) -> Result; +} + +/// The production renderer: runs citrate-core's `citrate-templates` program, +/// +/// ```text +/// citrate-templates render --root --template --tier +/// --out [--param key=value]... +/// ``` +/// +/// which prints a JSON report on stdout (exit 0) or an error on stderr. It runs through the +/// shell runner: argv only, a scrubbed environment, a timeout, and (with the OS sandbox) no +/// network, writes only in `dest`, reads of the templates folder. +pub struct CliTemplateRenderer { + runner: ShellRunner, + program: String, + templates: PathBuf, + tier: String, +} + +impl CliTemplateRenderer { + pub fn new( + bin: PathBuf, + templates: PathBuf, + tier: &str, + sandbox: SandboxPolicy, + ) -> Result { + if !bin.is_absolute() || !templates.is_absolute() { + return Err( + "the template renderer and the templates folder must be absolute paths".into(), + ); + } + if !matches!(tier, "T0" | "T1" | "T2") { + return Err(format!( + "{TEMPLATE_TIER_ENV} must be T0, T1 or T2, not {tier:?}" + )); + } + let program = bin + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .ok_or("the template renderer path names no program")?; + let dir = bin + .parent() + .map(Path::to_path_buf) + .ok_or("the template renderer path has no folder")?; + let sandbox = sandbox.with_extra_read_roots(vec![templates.clone()]); + let policy = ShellPolicy::new( + Allowlist::empty().allow(&program, ArgPolicy::Any), + vec![dir], + ) + .map_err(|e| e.to_string())? + .with_default_timeout(RENDER_TIMEOUT) + .with_max_timeout(RENDER_TIMEOUT) + .with_sandbox(sandbox); + // The run's only writable folder is the new project (its cwd). + let runner = ShellRunner::new(policy, |_cwd: &Path| Ok(())); + Ok(CliTemplateRenderer { + runner, + program, + templates, + tier: tier.to_string(), + }) + } +} + +impl TemplateRenderer for CliTemplateRenderer { + fn render( + &self, + template: &str, + params: &BTreeMap, + dest: &Path, + ) -> Result { + let mut args: Vec = vec![ + "render".into(), + "--root".into(), + self.templates.display().to_string(), + "--template".into(), + template.into(), + "--tier".into(), + self.tier.clone(), + "--out".into(), + dest.display().to_string(), + ]; + for (k, v) in params { + args.push("--param".into()); + args.push(format!("{k}={v}")); + } + let req = RunRequest::new(&self.program, args, dest).timeout(RENDER_TIMEOUT); + let r = self.runner.run(&req).map_err(|e| e.to_string())?; + if !r.passed() { + let why = r.stderr.trim(); + return Err(format!( + "the template renderer failed (exit {:?}){}", + r.exit_code, + if why.is_empty() { + String::new() + } else { + format!(": {}", clip(why, 2000)) + } + )); + } + serde_json::from_str(r.stdout.trim()) + .map_err(|_| "the template renderer did not print a JSON report".to_string()) + } +} + +fn clip(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let mut out: String = s.chars().take(max).collect(); + out.push_str("..."); + out + } +} + +/// `.citrate/project.json`: what Hermes created, where, when, and from which template. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectMarker { + pub version: u32, + pub name: String, + /// The canonical project folder. + pub root: String, + pub created_by: String, + /// Unix seconds. + pub created_at: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub template: Option, + #[serde(skip_serializing_if = "BTreeMap::is_empty", default)] + pub params: BTreeMap, +} + +/// The tool offered to the model. +pub fn project_create_spec() -> ToolSpec { + ToolSpec { + name: PROJECT_CREATE_TOOL.into(), + description: "Create a new project folder in the member's projects folder, optionally from a bundled template, and get its absolute path. The member approves it first. Inside the new project you may create build files such as package.json, foundry.toml and remappings.txt with the file tools.".into(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "name": { + "type": "string", "maxLength": MAX_NAME_LEN, + "description": "The folder name: letters, digits, '.', '_' or '-', starting with a letter or digit (for example demo or my-token)." + }, + "template": { + "type": "string", + "description": "Optional bundled template id (for example erc20 or hello-mint)." + }, + "params": { + "type": "object", "additionalProperties": {"type": "string"}, + "description": "Optional template parameters, such as name and symbol." + } + }, + "required": ["name"], + }), + host: HostKind::Sidecar, + annotations: ToolAnnotations { + read_only: false, + destructive: false, + idempotent: false, + open_world: false, + effect: Some(Effect::Write), + // The result is the sidecar's own (paths and the renderer's report). + trust: Some(Trust::Trusted), + }, + } +} + +/// Whether `name` is one plain folder name (see the module docs). +pub fn valid_name(name: &str) -> Result<(), String> { + let refuse = |why: &str| Err(format!("the project name {name:?} {why}")); + if name.is_empty() { + return refuse("is empty"); + } + if name.chars().count() > MAX_NAME_LEN { + return refuse(&format!("is longer than {MAX_NAME_LEN} characters")); + } + let first = name.chars().next().unwrap_or('.'); + if !first.is_ascii_alphanumeric() { + return refuse("must start with a letter or a digit"); + } + if name.contains("..") { + return refuse("may not contain '..'"); + } + if !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + { + return refuse("may only use letters, digits, '.', '_' and '-' (no path separators)"); + } + Ok(()) +} + +fn valid_template(id: &str) -> Result<(), String> { + if !id.is_empty() + && id.len() <= MAX_NAME_LEN + && id + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { + Ok(()) + } else { + Err(format!( + "the template id {id:?} is not a plain id (a-z, 0-9, '-')" + )) + } +} + +/// The model's request. +struct Request { + name: String, + template: Option, + params: BTreeMap, +} + +fn parse(raw: &str) -> Result { + let raw = if raw.trim().is_empty() { "{}" } else { raw }; + let args: serde_json::Map = match serde_json::from_str(raw) { + Ok(serde_json::Value::Object(m)) => m, + _ => return Err("the arguments must be a JSON object".into()), + }; + let name = match args.get("name") { + Some(serde_json::Value::String(s)) => s.clone(), + _ => return Err("name (the new folder's name) is required".into()), + }; + valid_name(&name)?; + let template = match args.get("template") { + None | Some(serde_json::Value::Null) => None, + Some(serde_json::Value::String(t)) if t.is_empty() => None, + Some(serde_json::Value::String(t)) => { + valid_template(t)?; + Some(t.clone()) + } + Some(_) => return Err("template must be a string".into()), + }; + let mut params = BTreeMap::new(); + match args.get("params") { + None | Some(serde_json::Value::Null) => {} + Some(serde_json::Value::Object(m)) => { + if m.len() > MAX_PARAMS { + return Err(format!("at most {MAX_PARAMS} template parameters")); + } + for (k, v) in m { + let v = v + .as_str() + .ok_or_else(|| format!("template parameter {k} must be a string"))?; + let key_ok = !k.is_empty() + && k.len() <= MAX_PARAM_LEN + && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'); + if !key_ok || v.len() > MAX_PARAM_LEN || v.contains('\0') { + return Err(format!( + "template parameter {k:?} must be a plain key with a value of at most {MAX_PARAM_LEN} bytes" + )); + } + params.insert(k.clone(), v.to_string()); + } + } + Some(_) => return Err("params must be an object of strings".into()), + } + if !params.is_empty() && template.is_none() { + return Err("params are only for a template".into()); + } + Ok(Request { + name, + template, + params, + }) +} + +/// `project_create` for one session turn. +pub struct ProjectCreateHost { + cfg: Arc, + grants: Arc, + actions: Option>, + taint: TaintState, + stop: StopFlag, +} + +impl ProjectCreateHost { + pub fn new( + cfg: Arc, + grants: Arc, + actions: Option>, + taint: TaintState, + stop: StopFlag, + ) -> Self { + ProjectCreateHost { + cfg, + grants, + actions, + taint, + stop, + } + } + + pub fn handles(name: &str) -> bool { + name == PROJECT_CREATE_TOOL + } + + /// The projects root, checked against the grants now: its canonical path. + fn checked_root(&self) -> Result { + let root = &self.cfg.root; + if !root.is_dir() { + return Err(format!( + "the projects folder {} does not exist yet; the app creates it when the member lets Hermes work there", + root.display() + )); + } + self.grants.shell_scope(root).map(|(c, _)| c).map_err(|e| { + format!( + "the projects folder {} is not granted for reading and writing ({e}); the member can allow it in the app", + root.display() + ) + }) + } + + /// Validate, ask, then create. `reason` names why the card is shown. + fn ask_and_create(&self, call: &ToolCall, reason: &str) -> ToolOutcome { + if call.name != PROJECT_CREATE_TOOL { + return ToolOutcome::Error(format!("'{}' is not project_create", call.name)); + } + let req = match parse(&call.arguments) { + Ok(r) => r, + Err(e) => return ToolOutcome::Error(e), + }; + if req.template.is_some() && self.cfg.renderer.is_none() { + return ToolOutcome::Error( + "this app cannot render templates from Hermes (no template renderer is configured); create the project without a template".into(), + ); + } + let root = match self.checked_root() { + Ok(r) => r, + Err(e) => return ToolOutcome::Error(e), + }; + let dest = root.join(&req.name); + if std::fs::symlink_metadata(&dest).is_ok() { + return ToolOutcome::Error(format!( + "{} already exists; pick another name (an existing folder is never reused)", + dest.display() + )); + } + let Some(actions) = &self.actions else { + return ToolOutcome::Denied( + "creating a project needs the member's approval, and no member can be asked in this session (it was not opened by an app that shows approval cards)".into(), + ); + }; + let mut card = ActionPending::for_call( + call, + reason, + if self.taint.is_tainted() { + self.taint.sources().unwrap_or_default() + } else { + Vec::new() + }, + ); + card.summary = match &req.template { + Some(t) => format!( + "create the project folder {} from the {t} template", + dest.display() + ), + None => format!("create the project folder {}", dest.display()), + }; + if let Err(why) = actions.request(card, &self.stop) { + return ToolOutcome::Denied(why); + } + if self.stop.is_stopped() { + return ToolOutcome::Denied("the session was stopped; nothing was created".into()); + } + // Checked again: a grant revoked while the card was open stops it. + let root = match self.checked_root() { + Ok(r) => r, + Err(e) => return ToolOutcome::Error(e), + }; + let dest = root.join(&req.name); + match self.create(&dest, &req) { + Ok(v) => ToolOutcome::Ok(v.to_string()), + Err(e) => ToolOutcome::Error(e), + } + } + + fn create(&self, dest: &Path, req: &Request) -> Result { + // `create_dir` (not `_all`): the root exists, and an existing folder is never reused. + std::fs::create_dir(dest) + .map_err(|e| format!("{} could not be created: {e}", dest.display()))?; + let rendered = match (&req.template, &self.cfg.renderer) { + (Some(t), Some(r)) => match r.render(t, &req.params, dest) { + Ok(report) => Some(report), + Err(e) => { + let _ = std::fs::remove_dir_all(dest); + return Err(format!( + "the {t} template could not be rendered, so no project was created: {e}" + )); + } + }, + _ => None, + }; + let canonical = std::fs::canonicalize(dest).map_err(|e| e.to_string())?; + let marker = ProjectMarker { + version: 1, + name: req.name.clone(), + root: canonical.display().to_string(), + created_by: "hermes".into(), + created_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0), + template: req.template.clone(), + params: req.params.clone(), + }; + let marker_dir = canonical.join(MARKER_DIR); + let written = std::fs::create_dir_all(&marker_dir) + .and_then(|()| { + std::fs::write( + marker_dir.join(MARKER_FILE), + serde_json::to_vec_pretty(&marker).unwrap_or_default(), + ) + }) + .map_err(|e| format!("the project marker could not be written: {e}")); + if let Err(e) = written { + let _ = std::fs::remove_dir_all(&canonical); + return Err(e); + } + self.grants.add_project_root(&canonical)?; + Ok(serde_json::json!({ + "ok": true, + "tool": PROJECT_CREATE_TOOL, + "name": req.name, + "path": canonical, + "template": req.template, + "marker": marker_dir.join(MARKER_FILE), + "rendered": rendered, + "note": "This folder is a project: the file tools may create its build files (package.json, foundry.toml, remappings.txt, ...). Env files stay the member's.", + })) + } +} + +impl ToolHost for ProjectCreateHost { + fn execute(&self, call: &ToolCall) -> ToolOutcome { + let reason = match self.taint.record() { + Some(r) => format!( + "creating a project folder needs your approval, and this session read untrusted content (from {})", + r.source + ), + None => "creating a project folder needs your approval".to_string(), + }; + self.ask_and_create(call, &reason) + } + + fn honors_explicit_approval(&self) -> bool { + self.actions.is_some() + } + + /// The one card is the explicit approval; it carries the loop's reason. + fn execute_with_explicit_approval(&self, call: &ToolCall, reason: &str) -> ToolOutcome { + self.ask_and_create(call, reason) + } +} + +/// How the renderer runs in production: the OS sandbox when this machine has one. +pub fn production_render_sandbox() -> SandboxPolicy { + SandboxPolicy::new(SandboxMode::Preferred) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn names_are_one_plain_folder_name() { + for ok in ["demo", "my-token", "App_2", "v1.0", "a"] { + assert!(valid_name(ok).is_ok(), "{ok}"); + } + for bad in [ + "", "..", "../x", "a/b", "a\\b", ".hidden", "-x", "~x", "a..b", "a b", "a;b", "é", + "/abs", + ] { + assert!(valid_name(bad).is_err(), "{bad:?}"); + } + assert!(valid_name(&"x".repeat(MAX_NAME_LEN + 1)).is_err()); + } + + #[test] + fn parse_checks_template_and_params() { + assert!(parse( + r#"{"name":"d","template":"erc20","params":{"name":"Demo","symbol":"DMO"}}"# + ) + .is_ok()); + assert!(parse(r#"{"name":"d","template":"../x"}"#).is_err()); + assert!( + parse(r#"{"name":"d","params":{"a":"b"}}"#).is_err(), + "params need a template" + ); + assert!(parse(r#"{"name":"d","template":"erc20","params":{"a b":"c"}}"#).is_err()); + assert!(parse(r#"{"name":"d","template":"erc20","params":{"a":1}}"#).is_err()); + assert!(parse(r#"{"template":"erc20"}"#).is_err()); + assert!(parse("[]").is_err()); + } + + fn vars(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let m: std::collections::HashMap = pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + move |k| m.get(k).cloned() + } + + #[test] + fn config_is_off_unless_the_flag_is_exactly_1_and_defaults_under_home() { + let off = SandboxPolicy::off; + assert!( + ProjectsConfig::from_env_vars(vars(&[("HOME", "/h")]), off()) + .unwrap() + .is_none() + ); + assert!(ProjectsConfig::from_env_vars( + vars(&[(PROJECTS_ENV, "true"), ("HOME", "/h")]), + off() + ) + .unwrap() + .is_none()); + let c = ProjectsConfig::from_env_vars(vars(&[(PROJECTS_ENV, "1"), ("HOME", "/h")]), off()) + .unwrap() + .unwrap(); + assert_eq!(c.root, PathBuf::from("/h/Citrate/Projects")); + assert!(c.renderer.is_none()); + let c = ProjectsConfig::from_env_vars( + vars(&[ + (PROJECTS_ENV, "1"), + (PROJECTS_ROOT_ENV, "/w/p"), + ("HOME", "/h"), + ]), + off(), + ) + .unwrap() + .unwrap(); + assert_eq!(c.root, PathBuf::from("/w/p")); + assert!(ProjectsConfig::from_env_vars( + vars(&[ + (PROJECTS_ENV, "1"), + (PROJECTS_ROOT_ENV, "rel"), + ("HOME", "/h") + ]), + off() + ) + .is_err()); + assert!( + ProjectsConfig::from_env_vars( + vars(&[ + (PROJECTS_ENV, "1"), + ("HOME", "/h"), + (TEMPLATE_RENDERER_ENV, "/bin/r") + ]), + off() + ) + .is_err(), + "a renderer needs the templates folder too" + ); + let c = ProjectsConfig::from_env_vars( + vars(&[ + (PROJECTS_ENV, "1"), + ("HOME", "/h"), + (TEMPLATE_RENDERER_ENV, "/opt/c/citrate-templates"), + (TEMPLATES_ROOT_ENV, "/opt/c/templates"), + ]), + off(), + ) + .unwrap() + .unwrap(); + assert!(c.renderer.is_some()); + assert!(ProjectsConfig::from_env_vars( + vars(&[ + (PROJECTS_ENV, "1"), + ("HOME", "/h"), + (TEMPLATE_RENDERER_ENV, "/opt/c/citrate-templates"), + (TEMPLATES_ROOT_ENV, "/opt/c/templates"), + (TEMPLATE_TIER_ENV, "T9"), + ]), + off() + ) + .is_err()); + } + + #[test] + fn the_spec_is_a_trusted_sidecar_write_tool() { + let s = project_create_spec(); + assert_eq!(s.name, PROJECT_CREATE_TOOL); + assert_eq!(s.host, HostKind::Sidecar); + assert_eq!(s.annotations.effect, Some(Effect::Write)); + assert_eq!(s.annotations.trust, Some(Trust::Trusted)); + assert_eq!(s.parameters["required"], serde_json::json!(["name"])); + } +} diff --git a/agent-sidecar/src/projects_tests.rs b/agent-sidecar/src/projects_tests.rs new file mode 100644 index 0000000..88f288f --- /dev/null +++ b/agent-sidecar/src/projects_tests.rs @@ -0,0 +1,464 @@ +//! HGW-3 — `project_create`: a new project folder in the member's projects folder, after the +//! member approves it, optionally rendered from a bundled template, that the file tools may then +//! set up (build configuration included). +//! +//! BDD map: +//! - `project_create_asks_then_creates_the_folder_and_marker_and_the_project_is_writable` +//! - `bad_names_are_refused_before_anything_is_asked` +//! - `an_existing_folder_is_never_reused` +//! - `the_projects_root_must_exist_and_be_granted` +//! - `a_declined_card_creates_nothing` +//! - `without_an_hic_aware_app_nothing_is_created` +//! - `in_a_tainted_session_one_card_carries_the_taint_reason` +//! - `a_template_is_rendered_into_the_new_folder_and_a_failed_render_leaves_nothing` +//! - `the_cli_renderer_calls_the_program_with_the_documented_argv` (+ Seatbelt on macOS) + +#![cfg(unix)] + +use super::hgw_support::*; +use super::projects::*; +use super::*; +use citrate_agent_loop::AssistantTurn; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +fn projects_root(fx: &Fx) -> PathBuf { + let r = fx.home().join("Projects"); + std::fs::create_dir_all(&r).expect("projects root"); + r +} + +fn create_call(id: &str, args: serde_json::Value) -> citrate_agent_loop::ToolCall { + tool_call(id, PROJECT_CREATE_TOOL, args) +} + +/// One render request: the template, its parameters and the destination. +type Rendered = (String, BTreeMap, PathBuf); + +/// A renderer that writes `README.md` (and records what it was asked), or fails. +struct FakeRenderer { + fail: bool, + seen: std::sync::Mutex>, +} +impl TemplateRenderer for FakeRenderer { + fn render( + &self, + template: &str, + params: &BTreeMap, + dest: &Path, + ) -> Result { + self.seen.lock().unwrap_or_else(|p| p.into_inner()).push(( + template.into(), + params.clone(), + dest.to_path_buf(), + )); + // The destination is the new, empty folder. + assert_eq!(std::fs::read_dir(dest).map(|d| d.count()).unwrap_or(99), 0); + std::fs::write(dest.join("README.md"), format!("# {template}\n")) + .map_err(|e| e.to_string())?; + if self.fail { + return Err("unknown parameter `colour`".into()); + } + Ok(serde_json::json!({"files": ["README.md"]})) + } +} + +struct Run { + s: Arc, + answered: Vec, +} + +async fn run( + fx: &Fx, + calls: Vec, + cfg: ProjectsConfig, + roots: &[PathBuf], + hic_aware: bool, + decide: impl Fn(&'static str, &serde_json::Value) -> bool, +) -> Run { + let mut turns: Vec = calls + .into_iter() + .map(|c| AssistantTurn::tools(vec![c])) + .collect(); + turns.push(AssistantTurn::text("ok")); + let mgr = Arc::new(manager_with(fx, turns, Some(shell_open())).with_projects(Arc::new(cfg))); + let id = mgr + .create(create_req(fx.rw_doc_for(roots), hic_aware)) + .expect("session"); + let s = mgr.get(&id).expect("session"); + let app = app_for(mgr.clone()); + mgr.send(&id, "make a project".into(), None).expect("send"); + let answered = member_answers(&app, &s, &id, decide).await; + Run { s, answered } +} + +fn plain(root: PathBuf) -> ProjectsConfig { + ProjectsConfig { + root, + renderer: None, + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn project_create_asks_then_creates_the_folder_and_marker_and_the_project_is_writable() { + let fx = Fx::new("pc-create"); + let root = projects_root(&fx); + let demo = root.join("demo"); + let demo_shown = demo.clone(); + let r = run( + &fx, + vec![ + create_call("c1", serde_json::json!({"name": "demo"})), + fs_write("c2", &demo.join("package.json"), "{\"name\":\"demo\"}\n"), + ], + plain(root.clone()), + std::slice::from_ref(&root), + true, + move |route, card| { + if route == "actions" { + assert!( + !demo_shown.exists(), + "nothing is created before the member decides" + ); + } + let _ = card; + true + }, + ) + .await; + assert_eq!(r.answered.len(), 1, "{:?}", r.answered); + let card = &r.answered[0].card; + assert_eq!(card["tool"], PROJECT_CREATE_TOOL); + assert!( + card["summary"] + .as_str() + .unwrap_or_default() + .contains(&demo.display().to_string()), + "the card shows the exact folder: {card}" + ); + let res = result_of(&r.s, "c1").await; + assert_eq!(res["status"], "ok", "{res}"); + let body: serde_json::Value = + serde_json::from_str(res["content"].as_str().unwrap_or_default()).expect("json"); + assert_eq!(body["path"], serde_json::json!(demo)); + assert!(demo.is_dir()); + let marker: ProjectMarker = + serde_json::from_slice(&std::fs::read(demo.join(".citrate/project.json")).expect("marker")) + .expect("marker json"); + assert_eq!(marker.name, "demo"); + assert_eq!(marker.root, demo.display().to_string()); + assert_eq!(marker.created_by, "hermes"); + // The new folder is a project root: its build configuration is the agent's to write. + assert_eq!(result_of(&r.s, "c2").await["status"], "ok"); + assert!(demo.join("package.json").is_file()); + assert_eq!(r.s.grants().expect("grants").project_roots(), vec![demo]); +} + +#[tokio::test(flavor = "multi_thread")] +async fn bad_names_are_refused_before_anything_is_asked() { + let fx = Fx::new("pc-names"); + let root = projects_root(&fx); + let long = "x".repeat(65); + let names = ["../escape", "a/b", ".hidden", "", "..", long.as_str()]; + let calls = names + .iter() + .enumerate() + .map(|(i, n)| create_call(&format!("c{i}"), serde_json::json!({ "name": n }))) + .collect(); + let r = run( + &fx, + calls, + plain(root.clone()), + std::slice::from_ref(&root), + true, + |_, _| true, + ) + .await; + assert!(r.answered.is_empty(), "{:?}", r.answered); + for i in 0..names.len() { + let res = result_of(&r.s, &format!("c{i}")).await; + assert_eq!(res["status"], "error", "{res}"); + } + assert!(!fx.home().join("escape").exists()); + assert_eq!(std::fs::read_dir(&root).expect("root").count(), 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_existing_folder_is_never_reused() { + let fx = Fx::new("pc-exists"); + let root = projects_root(&fx); + std::fs::create_dir_all(root.join("demo")).expect("existing"); + std::fs::write(root.join("demo/keep.txt"), "mine").expect("keep"); + let r = run( + &fx, + vec![create_call("c1", serde_json::json!({"name": "demo"}))], + plain(root.clone()), + std::slice::from_ref(&root), + true, + |_, _| true, + ) + .await; + assert!(r.answered.is_empty()); + let res = result_of(&r.s, "c1").await; + assert!( + res["content"] + .as_str() + .unwrap_or_default() + .contains("already exists"), + "{res}" + ); + assert!(!root.join("demo/.citrate").exists()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_projects_root_must_exist_and_be_granted() { + let fx = Fx::new("pc-root"); + let missing = fx.home().join("NotThere"); + let r = run( + &fx, + vec![create_call("c1", serde_json::json!({"name": "demo"}))], + plain(missing.clone()), + &[fx.proj()], + true, + |_, _| true, + ) + .await; + let res = result_of(&r.s, "c1").await; + assert!( + res["content"] + .as_str() + .unwrap_or_default() + .contains("does not exist"), + "{res}" + ); + assert!(!missing.exists()); + // Exists, but is not granted. + let root = projects_root(&fx); + let r = run( + &fx, + vec![create_call("c1", serde_json::json!({"name": "demo"}))], + plain(root.clone()), + &[fx.proj()], + true, + |_, _| true, + ) + .await; + let res = result_of(&r.s, "c1").await; + assert!( + res["content"] + .as_str() + .unwrap_or_default() + .contains("not granted"), + "{res}" + ); + assert!(r.answered.is_empty()); + assert!(!root.join("demo").exists()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_declined_card_creates_nothing() { + let fx = Fx::new("pc-decline"); + let root = projects_root(&fx); + let r = run( + &fx, + vec![create_call("c1", serde_json::json!({"name": "demo"}))], + plain(root.clone()), + std::slice::from_ref(&root), + true, + |_, _| false, + ) + .await; + assert_eq!(r.answered.len(), 1); + assert_eq!(result_of(&r.s, "c1").await["status"], "denied"); + assert!(!root.join("demo").exists()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn without_an_hic_aware_app_nothing_is_created() { + let fx = Fx::new("pc-headless"); + let root = projects_root(&fx); + let r = run( + &fx, + vec![create_call("c1", serde_json::json!({"name": "demo"}))], + plain(root.clone()), + std::slice::from_ref(&root), + false, + |_, _| true, + ) + .await; + let res = result_of(&r.s, "c1").await; + assert_eq!(res["status"], "denied", "{res}"); + assert!(res["content"] + .as_str() + .unwrap_or_default() + .contains("no member can be asked")); + assert!(!root.join("demo").exists()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn in_a_tainted_session_one_card_carries_the_taint_reason() { + let fx = Fx::new("pc-taint"); + let root = projects_root(&fx); + let r = run( + &fx, + vec![ + shell("c0", &["echo", "x"], &root), + create_call("c1", serde_json::json!({"name": "demo"})), + ], + plain(root.clone()), + std::slice::from_ref(&root), + true, + |_, _| true, + ) + .await; + let actions: Vec<&Answered> = r.answered.iter().filter(|a| a.route == "actions").collect(); + assert_eq!(actions.len(), 1, "one card, not two: {:?}", r.answered); + let reason = actions[0].card["hicReason"].as_str().unwrap_or_default(); + assert!(reason.contains("shell_run"), "{reason}"); + assert_eq!(result_of(&r.s, "c1").await["status"], "ok"); + assert!(root.join("demo/.citrate/project.json").is_file()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_template_is_rendered_into_the_new_folder_and_a_failed_render_leaves_nothing() { + let fx = Fx::new("pc-template"); + let root = projects_root(&fx); + let ok = Arc::new(FakeRenderer { + fail: false, + seen: Default::default(), + }); + let r = run( + &fx, + vec![create_call( + "c1", + serde_json::json!({"name": "tok", "template": "erc20", "params": {"name": "Demo", "symbol": "DMO"}}), + )], + ProjectsConfig { + root: root.clone(), + renderer: Some(ok.clone()), + }, + std::slice::from_ref(&root), + true, + |_, _| true, + ) + .await; + let res = result_of(&r.s, "c1").await; + assert_eq!(res["status"], "ok", "{res}"); + assert!(r.answered[0].card["summary"] + .as_str() + .unwrap_or_default() + .contains("erc20")); + let seen = ok.seen.lock().expect("seen").clone(); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].0, "erc20"); + assert_eq!(seen[0].1.get("symbol").map(String::as_str), Some("DMO")); + assert_eq!(seen[0].2, root.join("tok")); + assert!(root.join("tok/README.md").is_file()); + let marker: ProjectMarker = + serde_json::from_slice(&std::fs::read(root.join("tok/.citrate/project.json")).expect("m")) + .expect("marker"); + assert_eq!(marker.template.as_deref(), Some("erc20")); + // A render that fails removes the new folder and says why. + let bad = Arc::new(FakeRenderer { + fail: true, + seen: Default::default(), + }); + let r = run( + &fx, + vec![create_call( + "c1", + serde_json::json!({"name": "broken", "template": "erc20"}), + )], + ProjectsConfig { + root: root.clone(), + renderer: Some(bad), + }, + std::slice::from_ref(&root), + true, + |_, _| true, + ) + .await; + let res = result_of(&r.s, "c1").await; + assert_eq!(res["status"], "error", "{res}"); + assert!( + res["content"] + .as_str() + .unwrap_or_default() + .contains("colour"), + "{res}" + ); + assert!(!root.join("broken").exists()); + // Without a renderer a template is refused before asking. + let r = run( + &fx, + vec![create_call( + "c1", + serde_json::json!({"name": "t2", "template": "erc20"}), + )], + plain(root.clone()), + std::slice::from_ref(&root), + true, + |_, _| true, + ) + .await; + assert!(r.answered.is_empty()); + assert!(!root.join("t2").exists()); +} + +/// A stand-in for citrate-core's `citrate-templates` program: records its argv and writes a file. +fn fake_cli(dir: &Path) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let bin = dir.join("citrate-templates"); + std::fs::write( + &bin, + "#!/bin/sh\nout=''\nprev=''\nfor a in \"$@\"; do if [ \"$prev\" = --out ]; then out=\"$a\"; fi; prev=\"$a\"; done\nprintf '%s\\n' \"$@\" > \"$out/argv.txt\"\necho '{\"files\":[\"argv.txt\"]}'\n", + ) + .expect("script"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod"); + bin +} + +fn check_cli(sandbox: citrate_agent_shell::sandbox::SandboxPolicy) { + let fx = Fx::new("pc-cli"); + let tools = fx.base.join("app"); + let templates = tools.join("templates"); + std::fs::create_dir_all(&templates).expect("templates"); + let bin = fake_cli(&tools); + let dest = fx.proj().join("new"); + std::fs::create_dir(&dest).expect("dest"); + let r = CliTemplateRenderer::new(bin, templates.clone(), "T1", sandbox).expect("renderer"); + let mut params = BTreeMap::new(); + params.insert("name".to_string(), "Demo".to_string()); + let report = r.render("erc20", ¶ms, &dest).expect("render"); + assert_eq!(report["files"], serde_json::json!(["argv.txt"])); + let argv = std::fs::read_to_string(dest.join("argv.txt")).expect("argv"); + let lines: Vec<&str> = argv.lines().collect(); + assert_eq!( + lines, + vec![ + "render", + "--root", + &templates.display().to_string(), + "--template", + "erc20", + "--tier", + "T1", + "--out", + &dest.display().to_string(), + "--param", + "name=Demo", + ] + ); +} + +#[test] +fn the_cli_renderer_calls_the_program_with_the_documented_argv() { + check_cli(citrate_agent_shell::sandbox::SandboxPolicy::off()); +} + +#[cfg(target_os = "macos")] +#[test] +fn the_cli_renderer_runs_under_seatbelt_and_writes_only_the_new_folder() { + use citrate_agent_shell::sandbox::{SandboxMode, SandboxPolicy}; + check_cli(SandboxPolicy::new(SandboxMode::Required)); +} diff --git a/agent-sidecar/src/sessions.rs b/agent-sidecar/src/sessions.rs index bdbb552..e81aef7 100644 --- a/agent-sidecar/src/sessions.rs +++ b/agent-sidecar/src/sessions.rs @@ -72,6 +72,10 @@ //! carry the same reason. Each approval releases one call and the session stays tainted. //! Without `hicAware` nobody can be asked, so such calls are declined with //! [`citrate_agent_loop::cannot_ask_reason`]. +//! - HGW-3: when projects are on (`CITRATE_HERMES_PROJECTS=1`, default off), every session opened +//! with folder grants also offers the sidecar-hosted `project_create` ([`crate::projects`]): +//! it creates `/` after the member approves the action card, and the new +//! folder becomes one of the session's project roots. The name is reserved while it is on. use std::collections::{HashMap, VecDeque}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -975,6 +979,8 @@ pub(crate) struct SidecarHost { pub(crate) actions: Option>, pub(crate) taint: TaintState, pub(crate) stop: StopFlag, + /// HGW-3: `project_create`, when projects are on and the session has grants. + pub(crate) projects: Option, } impl ToolHost for SidecarHost { @@ -984,6 +990,11 @@ impl ToolHost for SidecarHost { return s.execute(call); } } + if crate::projects::ProjectCreateHost::handles(&call.name) { + if let Some(p) = &self.projects { + return p.execute(call); + } + } if browser_tools::handles(&call.name) { if let Some(b) = &self.browser { return b.execute(call); @@ -1066,6 +1077,14 @@ impl ToolHost for SidecarHost { None => cannot_ask(), }; } + if crate::projects::ProjectCreateHost::handles(&call.name) { + return match &self.projects { + Some(p) if p.honors_explicit_approval() => { + p.execute_with_explicit_approval(call, reason) + } + _ => cannot_ask(), + }; + } if let Some(m) = &self.mcp { if m.handles(&call.name) { return if m.honors_explicit_approval() { @@ -1144,6 +1163,8 @@ pub struct SessionManager { uses: AtomicU64, /// US-2.2 AC2: `shell_run` for sessions opened with folder grants (default off). shell_run: Option>, + /// HGW-3: `project_create` for sessions opened with folder grants (default off). + projects: Option>, /// HUP-S2.6: the one writer of the decision records the nightly anchor batches. records: Option>, /// US-1.3 AC2: ask the model for a self-review of every workflow step attempt and record it @@ -1179,6 +1200,7 @@ impl SessionManager { mcp_registry: None, uses: AtomicU64::new(0), shell_run: None, + projects: None, records: None, self_review: false, } @@ -1226,6 +1248,12 @@ impl SessionManager { self } + /// HGW-3: offer `project_create` to every new session opened with folder grants. + pub fn with_projects(mut self, cfg: Arc) -> Self { + self.projects = Some(cfg); + self + } + /// Whether `shell_run` is on. pub fn shell_run_enabled(&self) -> bool { self.shell_run.is_some() @@ -1625,6 +1653,16 @@ impl SessionManager { } specs.push(crate::learn::learn_propose_spec()); } + if self.projects.is_some() + && specs + .iter() + .any(|t| t.name == crate::projects::PROJECT_CREATE_TOOL) + { + return Err(SessionError::Invalid(format!( + "the tool name '{}' is reserved by the sidecar while projects are on", + crate::projects::PROJECT_CREATE_TOOL + ))); + } if self.shell_run.is_some() && 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" @@ -1682,6 +1720,9 @@ impl SessionManager { Some(Arc::new(g)) } }; + if self.projects.is_some() && grants.is_some() { + specs.push(crate::projects::project_create_spec()); + } let shell = match (&self.shell_run, &grants) { (Some(cfg), Some(g)) => { specs.push(shell_run_spec()); @@ -2007,8 +2048,19 @@ impl SessionManager { session.hic_aware, ) }); + 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(), + )), + _ => None, + }; if file_host.is_some() || shell_host.is_some() + || projects_host.is_some() || skill_host.is_some() || capsule_host.is_some() || toolchain.is_some() @@ -2035,6 +2087,7 @@ impl SessionManager { actions: session.actions.clone(), taint: session.taint.clone(), stop: session.stop.clone(), + projects: projects_host, }), ); }