From 11e6f2150d24a42e066d34023b637a1855f4705c Mon Sep 17 00:00:00 2001 From: Larry Klosowski Date: Thu, 15 Oct 2026 11:34:41 -0700 Subject: [PATCH 1/2] feat(sidecar): untrusted-content taint escalates to HIC approval (HGW-1) Owner decision 2026-10-15 (HGW (2)). Before this change any shell_run output, even an approved `node -v`, tainted the session, and every later command and sidecar-hosted write was declined for the rest of the conversation ("which this tool's host cannot ask for"), with no way to clear it. Now, in a session opened with hicAware: true: - a tainted effectful sidecar call (fs_*, file_write, sheet_write, toolchain tools, capsules) waits on a new per-session action card (GET /sessions/:id/actions/pending, POST .../actions/decide {id, allow, subject}); the card shows the tool, the exact canonical arguments, a summary, the hicReason and every taint source; - shell_run keeps its own card and still asks after taint; the card now carries hicReason and taintSources; - an approval releases that one call; the session stays tainted, so the next action asks again (never global, never automatic); - decisions are recorded (kind sidecar.tool_call, write-ahead). Without hicAware (headless, scheduled) nobody can be asked: the call is declined with citrate_agent_loop::cannot_ask_reason, which names the taint source and says why. Tests: agent-loop taint_tests (+1), action_approvals unit (+3), action_approvals_tests session/route tests (+6), shell_run_tests (+2, one rewritten), files_tests taint case rewritten to the card flow. Workspace: 2241 -> 2253 passed, 0 failed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018wWZZ8GRVU9USh3kKQHsFe --- agent-loop/src/lib.rs | 19 +- agent-loop/tests/taint_tests.rs | 42 +++ agent-sidecar/src/action_approvals.rs | 357 ++++++++++++++++++++ agent-sidecar/src/action_approvals_tests.rs | 350 +++++++++++++++++++ agent-sidecar/src/files.rs | 5 +- agent-sidecar/src/files_tests.rs | 22 +- agent-sidecar/src/hgw_support.rs | 286 ++++++++++++++++ agent-sidecar/src/lib.rs | 119 +++++++ agent-sidecar/src/sessions.rs | 102 +++++- agent-sidecar/src/shell_run.rs | 76 ++++- agent-sidecar/src/shell_run_tests.rs | 133 ++++++-- 11 files changed, 1452 insertions(+), 59 deletions(-) create mode 100644 agent-sidecar/src/action_approvals.rs create mode 100644 agent-sidecar/src/action_approvals_tests.rs create mode 100644 agent-sidecar/src/hgw_support.rs diff --git a/agent-loop/src/lib.rs b/agent-loop/src/lib.rs index 0f02c56..a9f209f 100644 --- a/agent-loop/src/lib.rs +++ b/agent-loop/src/lib.rs @@ -231,6 +231,18 @@ pub trait ToolHost: Send + Sync { } } +/// HGW-1: why a tainted session's effectful call is declined when no member can be asked: the +/// host is not one that shows approval cards (a headless or scheduled run, or a client that did +/// not promise to route `hic: "required"` to a person). Hosts that decline for the same reason +/// use this text too, so the model and the log read one message. `source` names what tainted the +/// session, when known. +pub fn cannot_ask_reason(source: Option<&str>) -> String { + let from = source.map(|s| format!(" (from {s})")).unwrap_or_default(); + format!( + "this session read untrusted content{from}, so this action needs a member's explicit approval, and no member can be asked in this session (it was not opened by an app that shows approval cards, for example a headless or scheduled run)" + ) +} + /// The record of what tainted a session. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TaintRecord { @@ -912,10 +924,9 @@ pub fn run_turn_with( (None, None) if declined.is_some() => { ToolOutcome::Denied(declined.clone().unwrap_or_default()) } - (None, None) if refused_hic => ToolOutcome::Denied( - "this session read untrusted content and this action needs a member's explicit approval, which this tool's host cannot ask for" - .into(), - ), + (None, None) if refused_hic => ToolOutcome::Denied(cannot_ask_reason( + tools.taint().record().as_ref().map(|r| r.source.as_str()), + )), (None, None) => { ToolOutcome::Error(format!("'{}' is not available in this session", call.name)) } diff --git a/agent-loop/tests/taint_tests.rs b/agent-loop/tests/taint_tests.rs index 4e74ecf..b6739b4 100644 --- a/agent-loop/tests/taint_tests.rs +++ b/agent-loop/tests/taint_tests.rs @@ -344,6 +344,48 @@ fn a_host_that_cannot_ask_a_member_refuses_instead_of_running() { assert!(msg.content.starts_with("declined:"), "{}", msg.content); } +/// HGW-1: when nobody can be asked (a headless or scheduled session), the refusal says what +/// tainted the session and why it cannot ask, so the model (and a person reading the log) can +/// tell a member's "no" from "this run has no member to ask". +#[test] +fn a_headless_refusal_names_the_source_and_says_nobody_can_be_asked() { + let llm = ScriptLlm::new(vec![ + AssistantTurn::tools(vec![call("c1", "web_fetch"), call("c2", "write_file")]), + AssistantTurn::text("done"), + ]); + let host = Arc::new(RecHost::new(false, ToolOutcome::Ok("ok".into()))); + let tools = ToolRegistry::new(catalog()).with_host(HostKind::Core, host.clone()); + let sink = Sink::default(); + let mut history = vec![]; + run_turn( + &cfg(), + &llm, + &tools, + &sink, + &StopFlag::default(), + &mut history, + "go", + ); + let msg = history + .iter() + .find(|m| m.tool_call_id.as_deref() == Some("c2")) + .unwrap(); + assert!(msg.content.contains("from web_fetch"), "{}", msg.content); + assert!(msg.content.contains("explicit approval"), "{}", msg.content); + assert!( + msg.content.contains("no member can be asked"), + "{}", + msg.content + ); + assert_eq!( + msg.content, + format!( + "declined: {}. Nothing was done.", + cannot_ask_reason(Some("web_fetch")) + ) + ); +} + #[test] fn the_taint_persists_across_steps_and_turns() { let llm = ScriptLlm::new(vec![ diff --git a/agent-sidecar/src/action_approvals.rs b/agent-sidecar/src/action_approvals.rs new file mode 100644 index 0000000..82b20c8 --- /dev/null +++ b/agent-sidecar/src/action_approvals.rs @@ -0,0 +1,357 @@ +//! HGW-1 — the member's decisions on the sidecar's own actions after taint, per session. +//! +//! Owner decision 2026-10-15 (HGW (2)): once a session has read untrusted content (a command's +//! output, a web page, an MCP result), an effectful sidecar-hosted call (`fs_*`, `file_write`, +//! `sheet_write`, the toolchain tools, `project_create`, capsules) is not refused for the rest of +//! the conversation. It waits here as an approval card that citrate-core shows to the member +//! (`GET /sessions/:id/actions/pending`, decided with `POST /sessions/:id/actions/decide`): +//! +//! - the card shows the tool, the exact arguments that will be used (`subject`, canonical JSON), +//! a one-line summary, why it is gated (`hicReason`) and every source that tainted the session +//! (`taintSources`); +//! - a decision must carry the `subject` that was shown; anything else is refused and changes +//! nothing; +//! - an allow releases **that one call**, with those arguments. The session stays tainted, so +//! the next effectful call asks again (no approval is ever global); +//! - a decline, a session stop, or no decision within [`ACTION_APPROVAL_TIMEOUT`] declines it. +//! +//! The queue exists only for a session opened with `hicAware: true` (core's promise that a +//! `hic: "required"` card always reaches a person, with no automatic path). Without it the +//! sidecar declines tainted effectful calls with [`citrate_agent_loop::cannot_ask_reason`]. +//! `shell_run`, the browser and MCP keep their own cards (`/shell`, `/browser/actions`, `/mcp`), +//! which carry the same taint reason. Nothing here signs or holds a key (Rule 3). + +use citrate_agent_loop::{StopFlag, ToolCall}; +use serde::Serialize; +use std::sync::{Condvar, Mutex}; +use std::time::{Duration, Instant}; + +/// How long a card waits for the member before it is declined. +pub const ACTION_APPROVAL_TIMEOUT: Duration = Duration::from_secs(300); +/// At most this many cards wait at once per session. +pub const MAX_WAITING: usize = 8; +/// Longest summary line on a card. +pub const SUMMARY_MAX_CHARS: usize = 240; + +/// What the approval card shows. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ActionPending { + /// The approval id (`act-`), which the decision names. + pub id: String, + /// The loop's tool call id. + pub call_id: String, + pub tool: String, + /// Always `required`. + pub hic: String, + /// The exact arguments that will be used (canonical JSON); a decision must carry it back. + pub subject: String, + /// One line for a person: the tool and its main argument. + pub summary: String, + /// Why the member is asked (the loop's `hic_reason`). + pub hic_reason: String, + /// Every tool that brought untrusted content into the session since it was last clean. + pub taint_sources: Vec, + /// Seconds left before it is declined for want of a decision. + pub expires_in_secs: u64, +} + +impl ActionPending { + /// The card for `call` (its `id` is assigned when it is queued). + pub fn for_call(call: &ToolCall, reason: &str, taint_sources: Vec) -> Self { + let subject = canonical_arguments(&call.arguments); + ActionPending { + id: String::new(), + call_id: call.id.clone(), + tool: call.name.clone(), + hic: "required".into(), + summary: summary_line(&call.name, &subject), + subject, + hic_reason: reason.to_string(), + taint_sources, + expires_in_secs: 0, + } + } +} + +/// The arguments as canonical JSON (sorted keys, no whitespace); the raw text when it is not +/// JSON (the call is then refused by its tool after release, never run differently). +pub fn canonical_arguments(raw: &str) -> String { + let raw = if raw.trim().is_empty() { "{}" } else { raw }; + match serde_json::from_str::(raw) { + Ok(v) => sorted(v).to_string(), + Err(_) => raw.to_string(), + } +} + +/// Keys sorted at every level (whatever serde_json's map order is in this build). +fn sorted(v: serde_json::Value) -> serde_json::Value { + match v { + serde_json::Value::Object(m) => { + let mut entries: Vec<(String, serde_json::Value)> = m.into_iter().collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + serde_json::Value::Object(entries.into_iter().map(|(k, v)| (k, sorted(v))).collect()) + } + serde_json::Value::Array(items) => { + serde_json::Value::Array(items.into_iter().map(sorted).collect()) + } + other => other, + } +} + +/// `tool path` (or the first string argument), clipped to [`SUMMARY_MAX_CHARS`]. +fn summary_line(tool: &str, subject: &str) -> String { + let main = serde_json::from_str::(subject) + .ok() + .and_then(|v| { + let o = v.as_object()?.clone(); + ["path", "from", "project", "name", "cwd"] + .iter() + .find_map(|k| o.get(*k).and_then(|x| x.as_str()).map(str::to_string)) + .or_else(|| o.values().find_map(|x| x.as_str().map(str::to_string))) + }) + .unwrap_or_default(); + let line = if main.is_empty() { + tool.to_string() + } else { + format!("{tool} {main}") + }; + if line.chars().count() > SUMMARY_MAX_CHARS { + let mut s: String = line.chars().take(SUMMARY_MAX_CHARS).collect(); + s.push_str("..."); + s + } else { + line + } +} + +struct Waiting { + view: ActionPending, + deadline: Instant, + answer: Option, +} + +#[derive(Default)] +struct Inner { + next: u64, + waiting: Vec, +} + +/// One session's action cards. +pub struct ActionApprovals { + inner: Mutex, + cv: Condvar, + timeout: Duration, +} + +impl Default for ActionApprovals { + fn default() -> Self { + Self::new(ACTION_APPROVAL_TIMEOUT) + } +} + +impl ActionApprovals { + pub fn new(timeout: Duration) -> Self { + ActionApprovals { + inner: Mutex::new(Inner::default()), + cv: Condvar::new(), + timeout, + } + } + + fn lock(&self) -> std::sync::MutexGuard<'_, Inner> { + self.inner.lock().unwrap_or_else(|p| p.into_inner()) + } + + /// The cards waiting now, oldest first. + pub fn pending(&self) -> Vec { + let now = Instant::now(); + self.lock() + .waiting + .iter() + .filter(|w| w.answer.is_none()) + .map(|w| { + let mut v = w.view.clone(); + v.expires_in_secs = w.deadline.saturating_duration_since(now).as_secs(); + v + }) + .collect() + } + + /// The card with this id, if it is still waiting (for the decision record). + pub fn waiting(&self, id: &str) -> Option { + self.lock() + .waiting + .iter() + .find(|w| w.view.id == id && w.answer.is_none()) + .map(|w| w.view.clone()) + } + + /// The member's decision on card `id`. It must carry the subject that was shown; otherwise + /// (or when nothing with that id is waiting) it is refused and changes nothing. + pub fn decide(&self, id: &str, allow: bool, subject: &str) -> Result<(), String> { + let mut g = self.lock(); + let w = g + .waiting + .iter_mut() + .find(|w| w.view.id == id && w.answer.is_none()) + .ok_or_else(|| "that action is no longer waiting for a decision".to_string())?; + if w.view.subject != subject { + return Err( + "the decision does not match the action that is waiting; nothing was decided" + .to_string(), + ); + } + w.answer = Some(allow); + drop(g); + self.cv.notify_all(); + Ok(()) + } + + /// Park `view` until the member decides, it expires, or the session stops. `Ok(())` only for + /// an explicit allow. + pub fn request(&self, mut view: ActionPending, stop: &StopFlag) -> Result<(), String> { + let deadline = Instant::now() + self.timeout; + let mut g = self.lock(); + if g.waiting.iter().filter(|w| w.answer.is_none()).count() >= MAX_WAITING { + return Err("too many actions are already waiting for the member".to_string()); + } + g.next += 1; + let id = format!("act-{}", g.next); + view.id = id.clone(); + view.expires_in_secs = self.timeout.as_secs(); + g.waiting.push(Waiting { + view, + deadline, + answer: None, + }); + let outcome = loop { + let answer = g + .waiting + .iter() + .find(|w| w.view.id == id) + .and_then(|w| w.answer); + if let Some(a) = answer { + break if a { + Ok(()) + } else { + Err("the member declined this action".to_string()) + }; + } + if stop.is_stopped() { + break Err("the session was stopped before the member decided".to_string()); + } + let now = Instant::now(); + if now >= deadline { + break Err(format!( + "no decision within {}s, so the action was declined", + self.timeout.as_secs() + )); + } + let wait = (deadline - now).min(Duration::from_millis(100)); + g = match self.cv.wait_timeout(g, wait) { + Ok((guard, _)) => guard, + Err(p) => p.into_inner().0, + }; + }; + g.waiting.retain(|w| w.view.id != id); + outcome + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + fn call() -> ToolCall { + ToolCall { + id: "c1".into(), + name: "fs_write".into(), + arguments: r#"{ "path": "/p/a.txt", "content": "x" }"#.into(), + } + } + + fn wait_for_card(a: &ActionApprovals) -> ActionPending { + let deadline = Instant::now() + Duration::from_secs(3); + loop { + if let Some(p) = a.pending().into_iter().next() { + return p; + } + assert!(Instant::now() < deadline, "no card appeared"); + std::thread::sleep(Duration::from_millis(5)); + } + } + + #[test] + fn the_card_shows_canonical_arguments_a_summary_and_the_reason() { + let v = ActionPending::for_call(&call(), "because", vec!["shell_run".into()]); + assert_eq!(v.subject, r#"{"content":"x","path":"/p/a.txt"}"#); + assert_eq!(v.summary, "fs_write /p/a.txt"); + assert_eq!(v.hic, "required"); + assert_eq!(v.hic_reason, "because"); + let j = serde_json::to_value(&v).expect("json"); + for k in ["callId", "hicReason", "taintSources", "expiresInSecs"] { + assert!(j.get(k).is_some(), "camelCase field {k} missing: {j}"); + } + let long = ToolCall { + arguments: serde_json::json!({ "path": "x".repeat(500) }).to_string(), + ..call() + }; + let v = ActionPending::for_call(&long, "r", vec![]); + assert!(v.summary.chars().count() <= SUMMARY_MAX_CHARS + 3); + } + + #[test] + fn an_allow_needs_the_subject_that_was_shown() { + let a = Arc::new(ActionApprovals::default()); + let a2 = a.clone(); + let t = std::thread::spawn(move || { + a2.request( + ActionPending::for_call(&call(), "r", vec![]), + &StopFlag::default(), + ) + }); + let card = wait_for_card(&a); + assert!(card.id.starts_with("act-")); + assert!(a.decide(&card.id, true, "{}").is_err()); + assert!(a.decide("act-999", true, &card.subject).is_err()); + assert_eq!(a.waiting(&card.id).map(|c| c.call_id), Some("c1".into())); + assert!(a.decide(&card.id, true, &card.subject).is_ok()); + assert_eq!(t.join().expect("join"), Ok(())); + assert!(a.pending().is_empty()); + } + + #[test] + fn a_decline_a_stop_and_a_timeout_all_decline() { + let a = Arc::new(ActionApprovals::default()); + let a2 = a.clone(); + let t = std::thread::spawn(move || { + a2.request( + ActionPending::for_call(&call(), "r", vec![]), + &StopFlag::default(), + ) + }); + let card = wait_for_card(&a); + a.decide(&card.id, false, &card.subject).expect("decide"); + assert!(t.join().expect("join").is_err()); + + let stop = StopFlag::default(); + let s2 = stop.clone(); + let a3 = a.clone(); + let t = std::thread::spawn(move || { + a3.request(ActionPending::for_call(&call(), "r", vec![]), &s2) + }); + wait_for_card(&a); + stop.stop(); + assert!(t.join().expect("join").is_err()); + + let quick = ActionApprovals::new(Duration::from_millis(150)); + assert!(quick + .request( + ActionPending::for_call(&call(), "r", vec![]), + &StopFlag::default() + ) + .is_err()); + } +} diff --git a/agent-sidecar/src/action_approvals_tests.rs b/agent-sidecar/src/action_approvals_tests.rs new file mode 100644 index 0000000..f1cb77a --- /dev/null +++ b/agent-sidecar/src/action_approvals_tests.rs @@ -0,0 +1,350 @@ +//! HGW-1 — untrusted-content taint escalates to HIC approval instead of ending the conversation. +//! +//! Owner decision 2026-10-15 (HGW (2)): once a session has read untrusted content, every +//! side-effecting action goes to the member as an approval card that shows what is about to run +//! and why it is gated. An approval releases that one action; the session stays tainted, so the +//! next action asks again. Where nobody can be asked (a session not opened by an HIC-aware app, +//! for example a headless run), the action is declined with a message that says so. Taint still +//! never allows a silent or automatic action. +//! +//! BDD map: +//! - AT-1.1 (three or more consecutive `shell_run`s, file writes after taint): +//! `after_taint_commands_and_writes_go_to_a_card_and_run_when_approved`. +//! - Per-action, not global: `an_approval_releases_one_action_and_the_next_asks_again`. +//! - Exact subject: `a_decision_must_carry_the_arguments_that_were_shown`. +//! - Headless: `without_an_hic_aware_app_tainted_actions_are_declined_and_say_why`. +//! - Routes: `the_action_routes_need_the_bearer_and_a_known_session`. +//! - Records: `action_decisions_are_recorded_for_the_anchor`. + +#![cfg(unix)] + +use super::hgw_support::*; +use super::*; +use axum::http::StatusCode; +use citrate_agent_loop::AssistantTurn; + +fn reason_of(card: &serde_json::Value) -> String { + card["hicReason"].as_str().unwrap_or_default().to_string() +} + +#[tokio::test(flavor = "multi_thread")] +async fn after_taint_commands_and_writes_go_to_a_card_and_run_when_approved() { + let fx = Fx::new("taint-cards"); + let proj = fx.proj(); + let written = proj.join("src/index.js"); + let turns = vec![ + AssistantTurn::tools(vec![shell("c1", &["echo", "one"], &proj)]), + AssistantTurn::tools(vec![fs_write("c2", &written, "console.log('hi')\n")]), + AssistantTurn::tools(vec![shell("c3", &["echo", "two"], &proj)]), + AssistantTurn::tools(vec![shell("c4", &["echo", "three"], &proj)]), + AssistantTurn::text("Built."), + ]; + let mgr = Arc::new(manager_with(&fx, turns, Some(shell_open()))); + let id = mgr.create(create_req(fx.rw_doc(), true)).expect("session"); + let s = mgr.get(&id).expect("session"); + let app = app_for(mgr.clone()); + mgr.send(&id, "build it".into(), None).expect("send"); + let answered = member_answers(&app, &s, &id, |_, _| true).await; + + // Every call ran: one untainted command, then three tainted actions, each released by a card. + for c in ["c1", "c2", "c3", "c4"] { + assert_eq!(result_of(&s, c).await["status"], "ok", "{c}"); + } + assert_eq!( + std::fs::read_to_string(&written).expect("written"), + "console.log('hi')\n" + ); + let routes: Vec<&str> = answered.iter().map(|a| a.route).collect(); + assert_eq!(routes, vec!["shell", "actions", "shell", "shell"]); + // The first command was asked before any taint: no taint reason on its card. + assert!( + answered[0].card.get("hicReason").is_none(), + "{}", + answered[0].card + ); + // The write's card shows what will run and why it is gated. + let w = &answered[1].card; + assert_eq!(w["tool"], "fs_write"); + assert_eq!(w["hic"], "required"); + assert!( + w["id"].as_str().unwrap_or_default().starts_with("act-"), + "{w}" + ); + assert!(reason_of(w).contains("shell_run"), "{w}"); + assert_eq!(w["taintSources"], serde_json::json!(["shell_run"])); + assert!( + w["subject"] + .as_str() + .unwrap_or_default() + .contains("index.js"), + "the card shows the exact arguments: {w}" + ); + assert!( + w["summary"] + .as_str() + .unwrap_or_default() + .contains("fs_write"), + "{w}" + ); + // Later commands' cards carry the same reason. + for a in &answered[2..] { + assert!(reason_of(&a.card).contains("shell_run"), "{}", a.card); + assert_eq!(a.card["taintSources"], serde_json::json!(["shell_run"])); + } + // No "host cannot ask" decline anywhere in the session. + assert!(!events(&s).iter().any(|e| e["content"] + .as_str() + .unwrap_or_default() + .contains("no member can be asked"))); + // The session is still tainted: approvals never clear it. + assert!(s.taint().is_tainted()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_approval_releases_one_action_and_the_next_asks_again() { + let fx = Fx::new("taint-per-action"); + let proj = fx.proj(); + let a = proj.join("a.txt"); + let b = proj.join("b.txt"); + let turns = vec![ + AssistantTurn::tools(vec![shell("c1", &["echo", "x"], &proj)]), + AssistantTurn::tools(vec![fs_write("c2", &a, "A")]), + AssistantTurn::tools(vec![fs_write("c3", &b, "B")]), + AssistantTurn::text("ok"), + ]; + let mgr = Arc::new(manager_with(&fx, turns, Some(shell_open()))); + let id = mgr.create(create_req(fx.rw_doc(), true)).expect("session"); + let s = mgr.get(&id).expect("session"); + let app = app_for(mgr.clone()); + mgr.send(&id, "go".into(), None).expect("send"); + let b_shown = b.clone(); + let answered = member_answers(&app, &s, &id, move |route, card| { + // Nothing is written before the member decides. + if route == "actions" { + assert!(!b_shown.exists()); + } + // Allow the command and the first write; decline the second write. + !(route == "actions" + && card["subject"] + .as_str() + .unwrap_or_default() + .contains("b.txt")) + }) + .await; + assert_eq!( + answered.iter().filter(|a| a.route == "actions").count(), + 2, + "each write asked on its own" + ); + assert_eq!(std::fs::read_to_string(&a).expect("a"), "A"); + assert!(!b.exists(), "a declined action never runs"); + let r = result_of(&s, "c3").await; + assert_eq!(r["status"], "denied", "{r}"); + assert!( + r["content"] + .as_str() + .unwrap_or_default() + .contains("declined"), + "{r}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_decision_must_carry_the_arguments_that_were_shown() { + let fx = Fx::new("taint-subject"); + let proj = fx.proj(); + let f = proj.join("f.txt"); + let turns = vec![ + AssistantTurn::tools(vec![shell("c1", &["echo", "x"], &proj)]), + AssistantTurn::tools(vec![fs_write("c2", &f, "F")]), + AssistantTurn::text("ok"), + ]; + let mgr = Arc::new(manager_with(&fx, turns, Some(shell_open()))); + let id = mgr.create(create_req(fx.rw_doc(), true)).expect("session"); + let s = mgr.get(&id).expect("session"); + let app = app_for(mgr.clone()); + mgr.send(&id, "go".into(), None).expect("send"); + // Approve the command, then find the write's card. + let mut card = serde_json::Value::Null; + for _ in 0..1500 { + let (_, sh) = send(&app, "GET", &format!("/sessions/{id}/shell/pending"), None).await; + if let Some(c) = sh["pending"].as_array().and_then(|a| a.first()).cloned() { + let (st, _) = send( + &app, + "POST", + &format!("/sessions/{id}/shell/decide"), + Some(serde_json::json!({"id": c["id"], "allow": true, "argv": c["argv"], "cwd": c["cwd"]})), + ) + .await; + assert_eq!(st, StatusCode::OK); + } + let (_, ac) = send( + &app, + "GET", + &format!("/sessions/{id}/actions/pending"), + None, + ) + .await; + if let Some(c) = ac["pending"].as_array().and_then(|a| a.first()).cloned() { + card = c; + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert!(card.is_object(), "no action card appeared"); + let url = format!("/sessions/{id}/actions/decide"); + let (st, _) = send( + &app, + "POST", + &url, + Some(serde_json::json!({"id": card["id"], "allow": true, "subject": "{\"path\":\"/etc/x\"}"})), + ) + .await; + assert_eq!(st, StatusCode::CONFLICT); + assert!(!f.exists(), "a mismatched decision releases nothing"); + let (st, _) = send( + &app, + "POST", + &url, + Some(serde_json::json!({"id": "act-999", "allow": true, "subject": card["subject"]})), + ) + .await; + assert_eq!(st, StatusCode::CONFLICT); + let (st, _) = send(&app, "POST", &url, Some(serde_json::json!({"id": 1}))).await; + assert_eq!(st, StatusCode::BAD_REQUEST); + let (st, _) = send( + &app, + "POST", + &url, + Some(serde_json::json!({"id": card["id"], "allow": true, "subject": card["subject"]})), + ) + .await; + assert_eq!(st, StatusCode::OK); + wait_done(&s).await; + assert_eq!(std::fs::read_to_string(&f).expect("f"), "F"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn without_an_hic_aware_app_tainted_actions_are_declined_and_say_why() { + let fx = Fx::new("taint-headless"); + let proj = fx.proj(); + let f = proj.join("f.txt"); + let turns = vec![ + AssistantTurn::tools(vec![shell("c1", &["echo", "x"], &proj)]), + AssistantTurn::tools(vec![fs_write("c2", &f, "F")]), + AssistantTurn::tools(vec![shell("c3", &["echo", "y"], &proj)]), + AssistantTurn::text("ok"), + ]; + let mgr = Arc::new(manager_with(&fx, turns, Some(shell_open()))); + let id = mgr.create(create_req(fx.rw_doc(), false)).expect("session"); + let s = mgr.get(&id).expect("session"); + let app = app_for(mgr.clone()); + mgr.send(&id, "go".into(), None).expect("send"); + // The untainted first command still waits for a decision on the shell route, as before. + let answered = member_answers(&app, &s, &id, |_, _| true).await; + assert_eq!( + answered.len(), + 1, + "only the untainted command was asked: {answered:?}" + ); + assert_eq!(answered[0].route, "shell"); + for c in ["c2", "c3"] { + let r = result_of(&s, c).await; + assert_eq!(r["status"], "denied", "{r}"); + let msg = r["content"].as_str().unwrap_or_default(); + assert!(msg.contains("no member can be asked"), "{msg}"); + assert!(msg.contains("from shell_run"), "{msg}"); + } + assert!(!f.exists()); + let (st, body) = send( + &app, + "GET", + &format!("/sessions/{id}/actions/pending"), + None, + ) + .await; + assert_eq!(st, StatusCode::OK); + assert_eq!(body["pending"], serde_json::json!([])); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_action_routes_need_the_bearer_and_a_known_session() { + use axum::body::Body; + use axum::http::Request; + use tower::ServiceExt; + let fx = Fx::new("taint-routes"); + let mgr = Arc::new(manager_with(&fx, vec![], Some(shell_open()))); + let id = mgr.create(create_req(fx.rw_doc(), true)).expect("session"); + let app = app_for(mgr); + let resp = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/sessions/{id}/actions/pending")) + .body(Body::empty()) + .expect("req"), + ) + .await + .expect("resp"); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + let (st, _) = send(&app, "GET", "/sessions/nope/actions/pending", None).await; + assert_eq!(st, StatusCode::NOT_FOUND); + let (st, _) = send( + &app, + "POST", + "/sessions/nope/actions/decide", + Some(serde_json::json!({"id": "act-1", "allow": true, "subject": "{}"})), + ) + .await; + assert_eq!(st, StatusCode::NOT_FOUND); +} + +/// HUP-S2.6: every member decision on an action card lands in the decision log the nightly +/// anchor batches (write-ahead for an allow). +#[tokio::test(flavor = "multi_thread")] +async fn action_decisions_are_recorded_for_the_anchor() { + use citrate_agent_records::{Decision, DecisionLog, Entry, LogConfig, Outcome}; + let fx = Fx::new("taint-records"); + let records_dir = fx.base.join("records"); + let (log, _) = DecisionLog::open(&records_dir, LogConfig::default()).expect("log"); + let proj = fx.proj(); + let turns = vec![ + AssistantTurn::tools(vec![shell("c1", &["echo", "x"], &proj)]), + AssistantTurn::tools(vec![fs_write("c2", &proj.join("a.txt"), "A")]), + AssistantTurn::tools(vec![fs_write("c3", &proj.join("b.txt"), "B")]), + AssistantTurn::text("ok"), + ]; + let mgr = Arc::new(manager_with(&fx, turns, Some(shell_open())).with_records(Arc::new(log))); + let id = mgr.create(create_req(fx.rw_doc(), true)).expect("session"); + let s = mgr.get(&id).expect("session"); + let app = app_for(mgr.clone()); + mgr.send(&id, "go".into(), None).expect("send"); + member_answers(&app, &s, &id, |route, card| { + !(route == "actions" + && card["subject"] + .as_str() + .unwrap_or_default() + .contains("b.txt")) + }) + .await; + let mut recs = citrate_agent_records::read::page(&records_dir, None, 10_000).expect("records"); + recs.sort_by_key(|r| r.record.seq); + let decisions: Vec<(u64, Decision, String)> = recs + .iter() + .filter_map(|r| match &r.record.entry { + Entry::Decision(d) if d.kind == "sidecar.tool_call" => { + Some((r.record.seq, d.decision, d.subject.clone())) + } + _ => None, + }) + .collect(); + assert_eq!(decisions.len(), 2, "{decisions:?}"); + assert_eq!(decisions[0].1, Decision::Approved); + assert!(decisions[0].2.contains("fs_write"), "{decisions:?}"); + let outcome = recs.iter().find_map(|r| match &r.record.entry { + Entry::Outcome(o) if o.decision_seq == decisions[0].0 => Some(o.outcome), + _ => None, + }); + assert_eq!(outcome, Some(Outcome::Completed)); + assert_eq!(decisions[1].1, Decision::Denied); +} diff --git a/agent-sidecar/src/files.rs b/agent-sidecar/src/files.rs index 93aabda..384c407 100644 --- a/agent-sidecar/src/files.rs +++ b/agent-sidecar/src/files.rs @@ -46,8 +46,9 @@ //! **Honest scope.** A file changed between the grant check and the write is caught by the //! checkpoint's own path checks (no write through a symbolic link, no path through a symlinked //! directory), not by an OS sandbox. Taint (HUP-S2.7) is enforced by the loop: these tools are -//! effectful, so after the session reads untrusted content they are declined. This module never -//! holds a key and never signs (Rule 3). +//! effectful, so after the session reads untrusted content each call waits for the member on an +//! action card (HGW-1, [`crate::action_approvals`]), or is declined where nobody can be asked. +//! This module never holds a key and never signs (Rule 3). use std::fs; use std::io::{ErrorKind, Read, Write}; diff --git a/agent-sidecar/src/files_tests.rs b/agent-sidecar/src/files_tests.rs index 290040a..b3c0098 100644 --- a/agent-sidecar/src/files_tests.rs +++ b/agent-sidecar/src/files_tests.rs @@ -971,7 +971,7 @@ async fn the_file_tool_names_are_reserved_while_file_tools_are_on() { } #[tokio::test(flavor = "multi_thread")] -async fn after_untrusted_content_a_file_write_is_declined_and_nothing_is_written() { +async fn after_untrusted_content_a_file_write_waits_for_the_member_and_a_decline_writes_nothing() { let s = Scratch::new(); let (tools, store) = s.tools(); let target = s.proj().join("pwned.txt"); @@ -1012,12 +1012,30 @@ async fn after_untrusted_content_a_file_write_is_declined_and_nothing_is_written } tokio::time::sleep(Duration::from_millis(20)).await; } + // HGW-1: the write is not refused for the rest of the session; it waits on an action card. + // Nothing is written while it waits, and the member's decline writes nothing. + let mut card = None; + for _ in 0..500 { + if let Some(c) = sess.action_pending().unwrap_or_default().into_iter().next() { + card = Some(c); + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + let card = card.expect("the tainted write waits for the member"); + assert_eq!(card.tool, FS_WRITE_TOOL); + assert!(card.hic_reason.contains("web_fetch"), "{}", card.hic_reason); + assert!( + !target.exists(), + "nothing is written before the member decides" + ); + sess.action_decide(&card.id, false, &card.subject).unwrap(); events_until(&mgr, &id, "done", &mut after, &mut all).await; let w = all .iter() .find(|e| e["type"] == "tool_result" && e["call_id"] == "w1") .unwrap(); - assert_ne!(w["status"], "ok", "{w}"); + assert_eq!(w["status"], "denied", "{w}"); assert!(!target.exists()); assert!(store .steps(&SessionId::new(&id).unwrap()) diff --git a/agent-sidecar/src/hgw_support.rs b/agent-sidecar/src/hgw_support.rs new file mode 100644 index 0000000..cf02da6 --- /dev/null +++ b/agent-sidecar/src/hgw_support.rs @@ -0,0 +1,286 @@ +//! Test support for the HGW ("Hermes gets to work") session tests: a scratch home with a granted +//! project folder, a scripted model, a session manager with `shell_run` and the checkpointed file +//! tools on, the HTTP app, and a "member" that answers approval cards through the routes. + +#![cfg(all(test, unix))] +// Shared by several HGW test modules; each uses a different subset. +#![allow(dead_code)] + +use super::*; +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use citrate_agent_checkpoints::{CheckpointStore, Config}; +use citrate_agent_grants::{Access, FolderGrants, GrantRequest}; +use citrate_agent_loop::{AssistantTurn, CompletionRequest, LlmClient, LlmError, ToolCall}; +use citrate_agent_shell::sandbox::{SandboxMode, SandboxPolicy}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Mutex; +use std::time::Duration; +use tower::ServiceExt; + +pub(crate) const BEARER: &str = "test-bearer-token-0123456789"; +pub(crate) const MEMBER: &str = "0x00000000000000000000000000000000000000aa"; +static N: AtomicUsize = AtomicUsize::new(0); + +pub(crate) fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// `base/home/proj` (granted read + write), `base/home/other` (not granted) and the undo store +/// at `base/ckpt` (outside the home). +pub(crate) struct Fx { + pub base: PathBuf, +} + +impl Fx { + pub fn new(tag: &str) -> Self { + let base = std::env::temp_dir().join(format!( + "citrate-hgw-{tag}-{}-{}", + std::process::id(), + N.fetch_add(1, Ordering::SeqCst) + )); + let _ = std::fs::remove_dir_all(&base); + for d in ["home/proj/src", "home/other", "ckpt"] { + std::fs::create_dir_all(base.join(d)).expect("fixture dirs"); + } + Fx { + base: base.canonicalize().expect("canonical base"), + } + } + pub fn home(&self) -> PathBuf { + self.base.join("home") + } + pub fn proj(&self) -> PathBuf { + self.base.join("home/proj") + } + pub fn other(&self) -> PathBuf { + self.base.join("home/other") + } + /// A grant document over `roots` (read and write each). + pub fn rw_doc_for(&self, roots: &[PathBuf]) -> serde_json::Value { + let mut g = FolderGrants::new(self.home(), self.home()); + for r in roots { + for a in [Access::Read, Access::Write] { + g.grant(GrantRequest::folder(r, a, MEMBER, "build it"), now()) + .expect("grant"); + } + } + serde_json::to_value(g.state()).expect("grant doc") + } + pub fn rw_doc(&self) -> serde_json::Value { + self.rw_doc_for(&[self.proj()]) + } +} + +impl Drop for Fx { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.base); + } +} + +/// `shell_run` with the sandbox off: for tests of the approval flow itself, which must pass on +/// machines without a backend. Production always requires the sandbox. +pub(crate) fn shell_open() -> shell_run::ShellRunConfig { + shell_run::ShellRunConfig { + search_path: vec![PathBuf::from("/bin"), PathBuf::from("/usr/bin")], + sandbox: SandboxPolicy::new(SandboxMode::Off), + approval_timeout: Duration::from_secs(20), + } +} + +/// The model: replays `turns`, then answers "(done)". +pub(crate) struct Script(pub Mutex>); +impl LlmClient for Script { + fn complete(&self, _req: &CompletionRequest) -> Result { + let mut t = self.0.lock().unwrap_or_else(|p| p.into_inner()); + Ok(if t.is_empty() { + AssistantTurn::text("(done)") + } else { + t.remove(0) + }) + } +} + +/// A session manager with grants, the undo store and `shell` (when given). +pub(crate) fn manager_with( + fx: &Fx, + turns: Vec, + shell: Option, +) -> sessions::SessionManager { + let script = Arc::new(Script(Mutex::new(turns))); + let store = + Arc::new(CheckpointStore::open(&fx.base.join("ckpt"), Config::default()).expect("store")); + let mut mgr = sessions::SessionManager::new( + Arc::new(move |_ep: &sessions::LlmEndpoint| script.clone() as Arc), + Duration::from_secs(5), + ) + .with_grants_home(fx.home()) + .with_checkpoints(store); + if let Some(c) = shell { + mgr = mgr.with_shell_run(Arc::new(c)); + } + mgr +} + +/// `POST /sessions` body with `grants` and `hicAware`. +pub(crate) fn create_req(grants: serde_json::Value, hic_aware: bool) -> sessions::CreateSessionReq { + serde_json::from_value(serde_json::json!({ + "model": "gemma-4", + "systemPrompt": "You are Hermes.", + "llm": {"baseUrl": "http://127.0.0.1:18080/v1", "bearer": "k"}, + "tools": [], + "maxToolsPerRequest": 8, + "hicAware": hic_aware, + "grants": grants, + })) + .expect("create req") +} + +pub(crate) fn app_for(mgr: Arc) -> axum::Router { + app(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: mgr, + })) +} + +pub(crate) async fn send( + app: &axum::Router, + method: &str, + path: &str, + body: Option, +) -> (StatusCode, serde_json::Value) { + let req = Request::builder() + .method(method) + .uri(path) + .header("authorization", format!("Bearer {BEARER}")) + .header("content-type", "application/json") + .body(match body { + Some(b) => Body::from(b.to_string()), + None => Body::empty(), + }) + .expect("request"); + let resp = app.clone().oneshot(req).await.expect("response"); + let status = resp.status(); + let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20) + .await + .unwrap_or_default(); + ( + status, + serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null), + ) +} + +pub(crate) fn tool_call(id: &str, name: &str, args: serde_json::Value) -> ToolCall { + ToolCall { + id: id.into(), + name: name.into(), + arguments: args.to_string(), + } +} + +pub(crate) fn shell(id: &str, argv: &[&str], cwd: &Path) -> ToolCall { + tool_call( + id, + shell_run::SHELL_RUN_TOOL, + serde_json::json!({ "argv": argv, "cwd": cwd }), + ) +} + +pub(crate) fn fs_write(id: &str, path: &Path, content: &str) -> ToolCall { + tool_call( + id, + "fs_write", + serde_json::json!({ "path": path, "content": content }), + ) +} + +/// Every event of the session, as JSON. +pub(crate) fn events(s: &sessions::Session) -> Vec { + s.events_after(0) + .events + .iter() + .map(|e| serde_json::to_value(&e.event).unwrap_or(serde_json::Value::Null)) + .collect() +} + +/// The `tool_result` event for `call_id`, once it exists. +pub(crate) async fn result_of(s: &sessions::Session, call_id: &str) -> serde_json::Value { + for _ in 0..1500 { + if let Some(e) = events(s) + .into_iter() + .find(|e| e["type"] == "tool_result" && e["call_id"] == call_id) + { + return e; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("no tool_result for {call_id}"); +} + +/// Wait until the session's turn is over. +pub(crate) async fn wait_done(s: &sessions::Session) { + for _ in 0..3000 { + if !s.is_busy() && events(s).iter().any(|e| e["type"] == "done") { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("the turn never finished"); +} + +/// One card the member answered, and on which route. +#[derive(Debug, Clone)] +pub(crate) struct Answered { + pub route: &'static str, + pub card: serde_json::Value, + pub allow: bool, +} + +/// Plays the member: answers every shell and action card of session `id` with `decide(card)` +/// (true = allow), until the turn is over. Returns the cards in the order they were answered. +pub(crate) async fn member_answers( + app: &axum::Router, + s: &sessions::Session, + id: &str, + decide: impl Fn(&'static str, &serde_json::Value) -> bool, +) -> Vec { + let mut out = Vec::new(); + for _ in 0..3000 { + for route in ["shell", "actions"] { + let (st, body) = + send(app, "GET", &format!("/sessions/{id}/{route}/pending"), None).await; + assert_eq!(st, StatusCode::OK, "{route}: {body}"); + for card in body["pending"].as_array().cloned().unwrap_or_default() { + let allow = decide(route, &card); + let decision = if route == "shell" { + serde_json::json!({"id": card["id"], "allow": allow, "argv": card["argv"], "cwd": card["cwd"]}) + } else { + serde_json::json!({"id": card["id"], "allow": allow, "subject": card["subject"]}) + }; + let (st, b) = send( + app, + "POST", + &format!("/sessions/{id}/{route}/decide"), + Some(decision), + ) + .await; + assert_eq!(st, StatusCode::OK, "{route} decide: {b}"); + out.push(Answered { route, card, allow }); + } + } + if !s.is_busy() && events(s).iter().any(|e| e["type"] == "done") { + return out; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("the turn never finished; answered so far: {out:?}"); +} diff --git a/agent-sidecar/src/lib.rs b/agent-sidecar/src/lib.rs index e1f3d97..6466106 100644 --- a/agent-sidecar/src/lib.rs +++ b/agent-sidecar/src/lib.rs @@ -18,6 +18,7 @@ //! //! NB — this is NOT `hermes/` (the Discord command-plane bot). Different program, distinct binary. +pub mod action_approvals; pub mod anchor; pub mod browser; pub mod capsule_sandbox; @@ -299,6 +300,8 @@ pub fn app(state: Arc) -> Router { .route("/sessions/:id/shell/decide", post(shell_decide)) .route("/sessions/:id/mcp/pending", get(mcp_pending)) .route("/sessions/:id/mcp/decide", post(mcp_decide)) + .route("/sessions/:id/actions/pending", get(action_pending)) + .route("/sessions/:id/actions/decide", post(action_decide)) // HUP-S1.4 — tracks + briefs (the interview every client shares). .route("/tracks", get(tracks)) .route("/briefs", post(create_brief)) @@ -1047,6 +1050,118 @@ async fn mcp_pending( Ok(Json(serde_json::json!({ "pending": pending }))) } +/// HGW-1: `GET /sessions/:id/actions/pending`: the action cards waiting for the member (tainted +/// effectful sidecar calls in an HIC-aware session). A session without them has none. +async fn action_pending( + headers: HeaderMap, + State(st): State>, + Path(id): Path, +) -> Result, JsonErr> { + if !authorized(&headers, &st.bearer) { + return Err(json_err(StatusCode::UNAUTHORIZED, "unauthorized")); + } + let session = st + .sessions + .get(&id) + .ok_or_else(|| json_err(StatusCode::NOT_FOUND, "no such session"))?; + let pending = session.action_pending().unwrap_or_default(); + Ok(Json(serde_json::json!({ "pending": pending }))) +} + +#[derive(Deserialize)] +struct ActionDecideReq { + id: String, + allow: bool, + subject: String, +} + +/// HGW-1: `POST /sessions/:id/actions/decide {id, allow, subject}`: the member's decision on one +/// action card. `subject` must be the arguments that were shown; otherwise 409 and nothing is +/// decided. Recorded like the other HIC decisions (write-ahead for an allow). +async fn action_decide( + headers: HeaderMap, + State(st): State>, + Path(id): Path, + body: axum::body::Bytes, +) -> Result, JsonErr> { + if !authorized(&headers, &st.bearer) { + return Err(json_err(StatusCode::UNAUTHORIZED, "unauthorized")); + } + let session = st + .sessions + .get(&id) + .ok_or_else(|| json_err(StatusCode::NOT_FOUND, "no such session"))?; + let req: ActionDecideReq = serde_json::from_slice(&body) + .map_err(|_| json_err(StatusCode::BAD_REQUEST, "expected {id, allow, subject}"))?; + let card = session + .action_waiting(&req.id) + .filter(|c| c.subject == req.subject) + .ok_or_else(|| { + json_err( + StatusCode::CONFLICT, + "that action is no longer waiting, or the decision does not match what is waiting; nothing was decided", + ) + })?; + let subject = format!("{} with {}", card.tool, card.subject); + let log = st.sessions.records(); + let record = |allow: bool, log: &citrate_agent_records::DecisionLog| { + hic_records::record_member_decision( + log, + "sidecar.tool_call", + &subject, + allow, + "the member decided on the exact action shown, in a session that read untrusted content", + vec![citrate_agent_records::EvidenceRef { + kind: "sidecar_tool_call".to_string(), + uri: format!("sidecar:sessions/{id}/actions/{}", req.id), + digest: None, + }], + ) + }; + let allow_seq = match &log { + Some(log) if req.allow => match record(true, log) { + Ok(seq) => Some(seq), + Err(e) => return Err(json_err(StatusCode::SERVICE_UNAVAILABLE, &e)), + }, + _ => None, + }; + match session.action_decide(&req.id, req.allow, &req.subject) { + Ok(()) => { + if let Some(log) = &log { + match allow_seq { + Some(seq) => hic_records::record_outcome( + log, + seq, + citrate_agent_records::Outcome::Completed, + "released; the call's own result is in the session", + ), + None => { + if let Err(e) = record(false, log) { + eprintln!("citrate-agent-sidecar: {e}"); + } + } + } + } + Ok(Json(serde_json::json!({ "ok": true }))) + } + Err(e) => { + if let (Some(log), Some(seq)) = (&log, allow_seq) { + hic_records::record_outcome( + log, + seq, + citrate_agent_records::Outcome::Failed, + "not released (no longer waiting, or the subject differed)", + ); + } + let msg = match e { + sessions::SessionError::Invalid(m) => m, + _ => "that action is no longer waiting for a decision".to_string(), + }; + Err(json_err(StatusCode::CONFLICT, &msg)) + } + } +} + #[derive(Deserialize)] struct McpDecideReq { id: String, @@ -2156,12 +2271,16 @@ mod tests; #[cfg(test)] mod verify_probes_tests; +#[cfg(all(test, unix))] +mod action_approvals_tests; #[cfg(test)] mod browser_session_tests; #[cfg(test)] mod decide_route_tests; #[cfg(all(test, unix))] mod deploy_guard_session_tests; +#[cfg(all(test, unix))] +mod hgw_support; #[cfg(test)] mod search_session_tests; #[cfg(test)] diff --git a/agent-sidecar/src/sessions.rs b/agent-sidecar/src/sessions.rs index 37dad32..d83ff33 100644 --- a/agent-sidecar/src/sessions.rs +++ b/agent-sidecar/src/sessions.rs @@ -65,6 +65,13 @@ //! session opened with folder grants also offers the sidecar-hosted `shell_run` tool //! ([`crate::shell_run`]): each command waits for the member's decision on the session's //! `/shell` routes and runs only in the OS sandbox. The name is reserved while it is on. +//! - HGW-1 (owner decision 2026-10-15): taint escalates to HIC approval instead of ending the +//! conversation. In a session opened with `hicAware: true`, a tainted effectful sidecar-hosted +//! call waits for the member on the session's action cards (`/actions` routes, +//! [`crate::action_approvals`]); `shell_run`, MCP and browser calls use their own cards, which +//! 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`]. use std::collections::{HashMap, VecDeque}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -96,6 +103,7 @@ use citrate_agent_metering::{MeteringSink, SystemClock}; use citrate_agent_trajectory::TrajectoryRecorder; use serde::{Deserialize, Serialize}; +use crate::action_approvals::{ActionApprovals, ActionPending}; use crate::anchor::AnchorService; use crate::decide::DecideService; use crate::files::{FileTools, FileToolsHost}; @@ -545,6 +553,8 @@ pub struct Session { /// refused), and its MCP approval cards (present only for an HIC-aware client). mcp_offered: Option>>, mcp_approvals: Option>, + /// HGW-1: the cards for tainted effectful sidecar calls (present only for an HIC-aware app). + actions: Option>, /// 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)] @@ -593,6 +603,23 @@ impl Session { a.decide(id, allow, subject).map_err(SessionError::Invalid) } + /// 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> { + self.actions.as_ref().map(|a| a.pending()) + } + + /// HGW-1: the waiting action card `id` (for its decision record). + pub fn action_waiting(&self, id: &str) -> Option { + self.actions.as_ref().and_then(|a| a.waiting(id)) + } + + /// HGW-1: the member's decision on a waiting action card; it must carry the subject shown. + pub fn action_decide(&self, id: &str, allow: bool, subject: &str) -> Result<(), SessionError> { + let a = self.actions.as_ref().ok_or(SessionError::NotFound)?; + a.decide(id, allow, subject).map_err(SessionError::Invalid) + } + /// HUP-S1.2: how this session counts tokens and ranks tools and skills right now. pub fn retrieval_report(&self) -> RetrievalReport { RetrievalReport { @@ -929,6 +956,11 @@ pub(crate) struct SidecarHost { pub(crate) browser: Option, /// US-2.2 AC2: `shell_run`, when on and the session has grants. pub(crate) shell: Option, + /// HGW-1: the session's action cards (HIC-aware apps only), with its taint and stop, for + /// effectful calls after taint. + pub(crate) actions: Option>, + pub(crate) taint: TaintState, + pub(crate) stop: StopFlag, } impl ToolHost for SidecarHost { @@ -990,30 +1022,61 @@ impl ToolHost for SidecarHost { } /// HUP-S5.1: the browser can put an action in front of the member (its decision routes); - /// HUP-S4.1: so can MCP, through the session's MCP approval cards (HIC-aware clients only). - /// Without either this stays false, so the loop declines as before. + /// HUP-S4.1: so can MCP, through the session's MCP approval cards (HIC-aware clients only); + /// HGW-1: so can every other sidecar tool, through the session's action cards and the + /// `shell_run` card (HIC-aware clients only). Without any of them this stays false, so the + /// loop declines as before. fn honors_explicit_approval(&self) -> bool { self.browser.is_some() + || self.actions.is_some() || self .mcp .as_ref() .is_some_and(|m| m.honors_explicit_approval()) + || self + .shell + .as_ref() + .is_some_and(|s| s.honors_explicit_approval()) } fn execute_with_explicit_approval(&self, call: &ToolCall, reason: &str) -> ToolOutcome { + let cannot_ask = || { + ToolOutcome::Denied(citrate_agent_loop::cannot_ask_reason( + self.taint.record().as_ref().map(|r| r.source.as_str()), + )) + }; + // Tools with their own card show it (with the reason) themselves. + if call.name == SHELL_RUN_TOOL { + return match &self.shell { + Some(s) => s.execute_with_explicit_approval(call, reason), + None => cannot_ask(), + }; + } if let Some(m) = &self.mcp { - if m.handles(&call.name) && m.honors_explicit_approval() { - return m.execute_with_explicit_approval(call, reason); + if m.handles(&call.name) { + return if m.honors_explicit_approval() { + m.execute_with_explicit_approval(call, reason) + } else { + cannot_ask() + }; } } - match &self.browser { - Some(b) if browser_tools::handles(&call.name) => { - b.execute_with_explicit_approval(call, reason) + if let Some(b) = &self.browser { + if browser_tools::handles(&call.name) { + return b.execute_with_explicit_approval(call, reason); } - _ => ToolOutcome::Denied( - "this session read untrusted content and this action needs a member's explicit approval, which this tool's host cannot ask for" - .into(), - ), + } + // HGW-1: everything else waits on an action card; an allow releases this one call. + let Some(actions) = &self.actions else { + return cannot_ask(); + }; + let card = ActionPending::for_call(call, reason, self.taint.sources().unwrap_or_default()); + match actions.request(card, &self.stop) { + Ok(()) if self.stop.is_stopped() => { + ToolOutcome::Denied("the session was stopped; nothing was done".into()) + } + Ok(()) => self.execute(call), + Err(why) => ToolOutcome::Denied(why), } } } @@ -1525,6 +1588,8 @@ impl SessionManager { // 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) .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 let Some(t) = specs.iter().find(|t| browser_tools::handles(&t.name)) { return Err(SessionError::Invalid(format!( @@ -1770,6 +1835,7 @@ impl SessionManager { decide_llm, mcp_offered, mcp_approvals, + actions, #[cfg(test)] event_hook: Mutex::new(None), }); @@ -1907,10 +1973,13 @@ impl SessionManager { ), )) }); - let shell_host = session - .shell - .as_ref() - .map(|s| s.host(session.taint.clone(), session.stop.clone())); + let shell_host = session.shell.as_ref().map(|s| { + s.host( + session.taint.clone(), + session.stop.clone(), + session.hic_aware, + ) + }); if file_host.is_some() || shell_host.is_some() || skill_host.is_some() @@ -1936,6 +2005,9 @@ impl SessionManager { learn: learn_host, browser: browser_host, shell: shell_host, + actions: session.actions.clone(), + taint: session.taint.clone(), + stop: session.stop.clone(), }), ); } diff --git a/agent-sidecar/src/shell_run.rs b/agent-sidecar/src/shell_run.rs index 9f03d47..2fbca56 100644 --- a/agent-sidecar/src/shell_run.rs +++ b/agent-sidecar/src/shell_run.rs @@ -7,8 +7,12 @@ //! //! One call: //! -//! 1. **Refused in a tainted session.** Once a session has read untrusted content it never runs a -//! command (the loop declines the call; this host checks again). +//! 1. **After taint, still asked, never automatic (HGW-1).** Once a session has read untrusted +//! content, a command is still put in front of the member, with the card saying why it is +//! gated (`hicReason`, `taintSources`), but only in a session opened by an HIC-aware app +//! (`hicAware: true`). Without one nobody can be asked, so the call is declined with +//! [`citrate_agent_loop::cannot_ask_reason`]. An approval releases that one command; the +//! session stays tainted. //! 2. **Validated before anything is asked.** The model gives `argv` (program first, as a bare //! name resolved from a fixed search path, never a path; argv only, no shell expansion) and an //! absolute `cwd`. The cwd must be covered by live read and write folder grants that reach @@ -25,7 +29,7 @@ //! and stdout/stderr captured up to [`OUTPUT_CAP`] bytes each. //! 5. **Reports** a toolchain envelope (`tool: "shell_run"`) with the run facts, the captured //! output and the sandbox; citrate-core adds it to the Activity monitor. The output is -//! untrusted, so it taints the session (and the next `shell_run` is then refused). +//! untrusted, so it taints the session (and every later effectful call then needs a card). //! //! Keyless: nothing here holds a key or signs (Rule 3). @@ -169,6 +173,12 @@ pub struct ShellPending { pub sandbox: SandboxSummary, /// Seconds left before it is declined for want of a decision. pub expires_in_secs: u64, + /// HGW-1: why this command is gated beyond the usual card, when the session is tainted. + #[serde(skip_serializing_if = "Option::is_none")] + pub hic_reason: Option, + /// HGW-1: every tool that brought untrusted content into the session (empty when clean). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub taint_sources: Vec, } struct Waiting { @@ -319,14 +329,17 @@ impl ShellRunSession { &self.approvals } - /// The host for one turn of the session. - pub fn host(&self, taint: TaintState, stop: StopFlag) -> ShellRunHost { + /// The host for one turn of the session. `hic_aware`: the session was opened by an app that + /// always puts a `hic: "required"` card in front of a person, so a tainted session may still + /// ask; without it a tainted session's commands are declined. + pub fn host(&self, taint: TaintState, stop: StopFlag, hic_aware: bool) -> ShellRunHost { ShellRunHost { runner: self.runner.clone(), approvals: self.approvals.clone(), approval_timeout: self.approval_timeout, taint, stop, + hic_aware, } } } @@ -338,6 +351,7 @@ pub struct ShellRunHost { approval_timeout: Duration, taint: TaintState, stop: StopFlag, + hic_aware: bool, } /// The model's proposal. @@ -499,16 +513,12 @@ fn report(argv: &[String], r: &RunReport) -> ToolOutcome { ) } -impl ToolHost for ShellRunHost { - fn execute(&self, call: &ToolCall) -> ToolOutcome { +impl ShellRunHost { + /// Validate, ask the member (with the taint reason when there is one), re-check, run. + fn ask_and_run(&self, call: &ToolCall, hic_reason: Option<&str>) -> ToolOutcome { if call.name != SHELL_RUN_TOOL { return ToolOutcome::Error(format!("'{}' is not shell_run", call.name)); } - if self.taint.is_tainted() { - return ToolOutcome::Denied( - "this session read untrusted content, so it does not run commands".into(), - ); - } let proposal = match parse(&call.arguments) { Ok(p) => p, Err(e) => return refuse(RunStatus::Refused, e), @@ -532,6 +542,12 @@ impl ToolHost for ShellRunHost { timeout_secs: plan.timeout.as_secs(), sandbox: plan.sandbox.clone(), expires_in_secs: 0, + hic_reason: hic_reason.map(str::to_string), + taint_sources: if hic_reason.is_some() { + self.taint.sources().unwrap_or_default() + } else { + Vec::new() + }, }; let stop = self.stop.clone(); if let Err(why) = self @@ -559,6 +575,42 @@ impl ToolHost for ShellRunHost { Err(e) => refusal_for(&e), } } + + /// The reason a tainted session's command is gated (the loop's wording, naming the source). + fn taint_reason(&self) -> Option { + self.taint.record().map(|r| { + format!( + "this session read untrusted content (from {}), so this action needs your explicit approval", + r.source + ) + }) + } +} + +impl ToolHost for ShellRunHost { + fn execute(&self, call: &ToolCall) -> ToolOutcome { + // The loop routes tainted calls through `execute_with_explicit_approval`; a session that + // became tainted in between is checked again here and never runs a command unasked. + match self.taint_reason() { + Some(reason) => self.execute_with_explicit_approval(call, &reason), + None => self.ask_and_run(call, None), + } + } + + fn honors_explicit_approval(&self) -> bool { + self.hic_aware + } + + /// HGW-1: the command's own card is the explicit approval; it carries the reason. Without an + /// HIC-aware app nobody can be asked, so nothing is asked or run. + fn execute_with_explicit_approval(&self, call: &ToolCall, reason: &str) -> ToolOutcome { + if !self.hic_aware { + return ToolOutcome::Denied(citrate_agent_loop::cannot_ask_reason( + self.taint.record().as_ref().map(|r| r.source.as_str()), + )); + } + self.ask_and_run(call, Some(reason)) + } } #[cfg(test)] diff --git a/agent-sidecar/src/shell_run_tests.rs b/agent-sidecar/src/shell_run_tests.rs index 09e6d16..22d38af 100644 --- a/agent-sidecar/src/shell_run_tests.rs +++ b/agent-sidecar/src/shell_run_tests.rs @@ -1,7 +1,8 @@ //! US-2.2 AC2 — the general `shell_run` tool: the agent proposes an exact argv and cwd, the member //! sees exactly that (plus the program it resolves to and the sandbox) and decides, and only an //! approved command runs, inside the OS sandbox, in a live folder grant, with a timeout and -//! capped output. Off by default; refused in a tainted session; fails closed without a sandbox. +//! capped output. Off by default; after taint asked with the reason (HIC-aware apps) or refused; +//! fails closed without a sandbox. //! //! BDD map: //! - "Anything else shows the exact command + cwd for approval": @@ -10,7 +11,9 @@ //! `a_decision_must_carry_the_argv_and_cwd_that_were_shown`. //! - Grants: `a_cwd_outside_the_grants_is_refused_before_asking`, //! `a_read_only_or_shallow_grant_is_not_enough`, `a_revoked_grant_stops_an_approved_run`. -//! - Taint: `a_tainted_session_is_refused_and_nothing_is_asked`, +//! - Taint (HGW-1): `a_tainted_session_without_an_hic_aware_app_is_refused_and_nothing_is_asked`, +//! `a_tainted_hic_aware_session_asks_with_the_reason_and_runs_only_on_allow`, +//! `an_untainted_card_has_no_taint_reason`, //! `an_unattended_session_never_offers_a_run`. //! - Sandbox: `no_sandbox_means_no_run_and_no_question`, `the_approved_run_is_sandboxed` (macOS). //! - Timeout + capture: `a_command_past_its_timeout_is_killed_and_reported`, @@ -207,7 +210,7 @@ fn a_call_waits_for_the_member_and_shows_exactly_what_will_run() { let s = session(&fx, &cfg_open()); let approvals = s.approvals().clone(); let h = spawn( - s.host(TaintState::default(), StopFlag::default()), + s.host(TaintState::default(), StopFlag::default(), false), call("c1", &["echo", "hello $(id)"], &fx.proj().join("src")), ); let p = wait_pending(&approvals); @@ -239,7 +242,7 @@ fn an_approved_command_runs_and_reports() { let approvals = s.approvals().clone(); let marker = fx.proj().join("built.txt"); let h = spawn( - s.host(TaintState::default(), StopFlag::default()), + s.host(TaintState::default(), StopFlag::default(), false), call( "c1", &[ @@ -275,7 +278,7 @@ fn a_declined_command_never_runs() { let approvals = s.approvals().clone(); let marker = fx.proj().join("never.txt"); let h = spawn( - s.host(TaintState::default(), StopFlag::default()), + s.host(TaintState::default(), StopFlag::default(), false), call( "c1", &["sh", "-c", &format!("echo x > '{}'", marker.display())], @@ -297,7 +300,7 @@ fn a_decision_must_carry_the_argv_and_cwd_that_were_shown() { let s = session(&fx, &cfg_open()); let approvals = s.approvals().clone(); let h = spawn( - s.host(TaintState::default(), StopFlag::default()), + s.host(TaintState::default(), StopFlag::default(), false), call("c1", &["echo", "a"], &fx.proj()), ); let p = wait_pending(&approvals); @@ -323,7 +326,7 @@ fn an_unanswered_approval_expires_as_declined() { cfg.approval_timeout = Duration::from_millis(200); let s = session(&fx, &cfg); let out = s - .host(TaintState::default(), StopFlag::default()) + .host(TaintState::default(), StopFlag::default(), false) .execute(&call("c1", &["echo", "a"], &fx.proj())); match out { ToolOutcome::Denied(why) => assert!(why.contains("no decision"), "{why}"), @@ -339,7 +342,7 @@ fn a_session_stop_declines_a_waiting_command() { let approvals = s.approvals().clone(); let stop = StopFlag::default(); let h = spawn( - s.host(TaintState::default(), stop.clone()), + s.host(TaintState::default(), stop.clone(), false), call("c1", &["echo", "a"], &fx.proj()), ); wait_pending(&approvals); @@ -366,7 +369,7 @@ fn refused(out: ToolOutcome, needle: &str) { fn bad_arguments_are_refused_before_asking() { let fx = Fx::new(); let s = session(&fx, &cfg_open()); - let host = s.host(TaintState::default(), StopFlag::default()); + let host = s.host(TaintState::default(), StopFlag::default(), false); let raw = |args: serde_json::Value| { host.execute(&ToolCall { id: "c".into(), @@ -416,7 +419,7 @@ fn bad_arguments_are_refused_before_asking() { fn a_cwd_outside_the_grants_is_refused_before_asking() { let fx = Fx::new(); let s = session(&fx, &cfg_open()); - let host = s.host(TaintState::default(), StopFlag::default()); + let host = s.host(TaintState::default(), StopFlag::default(), false); refused(host.execute(&call("c", &["echo"], &fx.other())), "refused"); refused(host.execute(&call("c", &["echo"], &fx.home())), "refused"); assert!(s.approvals().pending().is_empty()); @@ -433,7 +436,7 @@ fn a_read_only_or_shallow_grant_is_not_enough() { )]); let s = ShellRunSession::new(&cfg_open(), fx.grants(&read_only)).unwrap(); refused( - s.host(TaintState::default(), StopFlag::default()) + s.host(TaintState::default(), StopFlag::default(), false) .execute(&call("c", &["echo"], &fx.proj())), "refused", ); @@ -443,7 +446,7 @@ fn a_read_only_or_shallow_grant_is_not_enough() { ]); let s = ShellRunSession::new(&cfg_open(), fx.grants(&shallow)).unwrap(); refused( - s.host(TaintState::default(), StopFlag::default()) + s.host(TaintState::default(), StopFlag::default(), false) .execute(&call("c", &["echo"], &fx.proj())), "everything below", ); @@ -457,7 +460,7 @@ fn a_revoked_grant_stops_an_approved_run() { let approvals = s.approvals().clone(); let marker = fx.proj().join("late.txt"); let h = spawn( - s.host(TaintState::default(), StopFlag::default()), + s.host(TaintState::default(), StopFlag::default(), false), call( "c1", &["sh", "-c", &format!("echo x > '{}'", marker.display())], @@ -474,21 +477,103 @@ fn a_revoked_grant_stops_an_approved_run() { } #[test] -fn a_tainted_session_is_refused_and_nothing_is_asked() { +fn a_tainted_session_without_an_hic_aware_app_is_refused_and_nothing_is_asked() { let fx = Fx::new(); let s = session(&fx, &cfg_open()); let taint = TaintState::default(); taint.taint("read_url", "a web page"); - let out = s - .host(taint, StopFlag::default()) - .execute(&call("c1", &["echo", "a"], &fx.proj())); + let host = s.host(taint, StopFlag::default(), false); + assert!(!host.honors_explicit_approval()); + let out = host.execute(&call("c1", &["echo", "a"], &fx.proj())); match out { - ToolOutcome::Denied(why) => assert!(why.contains("untrusted"), "{why}"), + ToolOutcome::Denied(why) => { + assert!(why.contains("untrusted"), "{why}"); + assert!(why.contains("from read_url"), "{why}"); + assert!(why.contains("no member can be asked"), "{why}"); + } other => panic!("{other:?}"), } assert!(s.approvals().pending().is_empty()); } +/// HGW-1: after taint, an HIC-aware session still asks; the card says why the command is gated +/// and names every taint source, and only an explicit allow runs it. Taint is not cleared. +#[test] +fn a_tainted_hic_aware_session_asks_with_the_reason_and_runs_only_on_allow() { + let fx = Fx::new(); + let s = session(&fx, &cfg_open()); + let approvals = s.approvals().clone(); + let taint = TaintState::default(); + taint.taint("read_url", "a web page"); + taint.taint("shell_run", "output"); + let marker = fx.proj().join("tainted-run.txt"); + let host = s.host(taint.clone(), StopFlag::default(), true); + assert!(host.honors_explicit_approval()); + let c = call( + "c1", + &["sh", "-c", &format!("echo x > '{}'", marker.display())], + &fx.proj(), + ); + // Through the loop's explicit-approval path and through a plain execute (taint re-checked). + for plain in [false, true] { + let h = s.host(taint.clone(), StopFlag::default(), true); + let c2 = c.clone(); + let t = std::thread::spawn(move || { + if plain { + h.execute(&c2) + } else { + h.execute_with_explicit_approval(&c2, "gated: read_url") + } + }); + let p = wait_pending(&approvals); + let reason = p + .hic_reason + .clone() + .expect("a tainted card carries the reason"); + assert!(reason.contains("read_url"), "{reason}"); + assert_eq!(p.taint_sources, vec!["read_url", "shell_run"]); + let v = serde_json::to_value(&p).unwrap(); + assert!( + v.get("hicReason").is_some() && v.get("taintSources").is_some(), + "{v}" + ); + assert!(!marker.exists(), "nothing runs before the member decides"); + let allow = plain; + approvals.decide(&p.id, allow, &p.argv, &p.cwd).unwrap(); + let out = t.join().unwrap(); + if allow { + assert!(matches!(out, ToolOutcome::Ok(_)), "{out:?}"); + assert!(marker.exists()); + } else { + assert!(matches!(out, ToolOutcome::Denied(_)), "{out:?}"); + assert!(!marker.exists()); + } + } + assert!(taint.is_tainted(), "an approval never clears the taint"); + drop(host); +} + +#[test] +fn an_untainted_card_has_no_taint_reason() { + let fx = Fx::new(); + let s = session(&fx, &cfg_open()); + let approvals = s.approvals().clone(); + let h = spawn( + s.host(TaintState::default(), StopFlag::default(), true), + call("c1", &["echo", "a"], &fx.proj()), + ); + let p = wait_pending(&approvals); + assert!(p.hic_reason.is_none()); + assert!(p.taint_sources.is_empty()); + let v = serde_json::to_value(&p).unwrap(); + assert!( + v.get("hicReason").is_none() && v.get("taintSources").is_none(), + "{v}" + ); + approvals.decide(&p.id, false, &p.argv, &p.cwd).unwrap(); + assert!(matches!(h.join().unwrap(), ToolOutcome::Denied(_))); +} + #[test] fn no_sandbox_means_no_run_and_no_question() { let fx = Fx::new(); @@ -498,7 +583,7 @@ fn no_sandbox_means_no_run_and_no_question() { ); let s = session(&fx, &cfg); let out = s - .host(TaintState::default(), StopFlag::default()) + .host(TaintState::default(), StopFlag::default(), false) .execute(&call("c1", &["echo", "a"], &fx.proj())); refused(out, "sandbox"); assert!(s.approvals().pending().is_empty()); @@ -529,7 +614,7 @@ fn a_command_past_its_timeout_is_killed_and_reported() { }; let started = std::time::Instant::now(); let out = s - .host(TaintState::default(), StopFlag::default()) + .host(TaintState::default(), StopFlag::default(), false) .execute(&c); a.join().unwrap(); assert!(started.elapsed() < Duration::from_secs(10)); @@ -545,7 +630,7 @@ fn output_is_capped() { let s = session(&fx, &cfg_open()); let a = approve_next(s.approvals().clone()); let out = s - .host(TaintState::default(), StopFlag::default()) + .host(TaintState::default(), StopFlag::default(), false) .execute(&call( "c1", &["sh", "-c", "yes x | head -c 200000"], @@ -571,7 +656,7 @@ fn the_approved_run_is_sandboxed() { let inside = fx.proj().join("src/ok.txt"); let outside = fx.other().join("escape.txt"); let h = spawn( - s.host(TaintState::default(), StopFlag::default()), + s.host(TaintState::default(), StopFlag::default(), false), call( "c1", &[ @@ -621,7 +706,7 @@ fn backend_override_reaches_the_card() { let approvals = s.approvals().clone(); let stop = StopFlag::default(); let h = spawn( - s.host(TaintState::default(), stop.clone()), + s.host(TaintState::default(), stop.clone(), false), call("c1", &["echo", "a"], &fx.proj()), ); let p = wait_pending(&approvals); @@ -900,7 +985,7 @@ async fn an_unattended_session_never_offers_a_run() { /// HUP-S2.6: every member decision on a `shell_run` command lands in the decision log the nightly /// anchor batches: a refused (mismatched) allow closes as failed, the real allow as completed, and -/// a deny is recorded as denied. (One command per session: after a run the session is tainted.) +/// a deny is recorded as denied. (One session per command keeps each log entry easy to find.) #[tokio::test(flavor = "multi_thread")] async fn shell_run_decisions_are_recorded_for_the_anchor() { use citrate_agent_records::{Decision, DecisionLog, Entry, LogConfig, Outcome}; From 9d8f71722b9d3efa6f7b511e2104e831fb4aca14 Mon Sep 17 00:00:00 2001 From: Larry Klosowski Date: Thu, 15 Oct 2026 12:24:44 -0700 Subject: [PATCH 2/2] feat(sidecar): approvals routes per #74, explicit taint clear, ask-not-retry (HGW-1) Aligns the HGW-1 interface with the HGW-W1 issue (#74): - routes are GET /sessions/:id/approvals/pending and POST /sessions/:id/approvals/:aid/decide {allow, subject}; - POST /sessions/:id/taint/clear {note}: the member clears the taint from an explicit card (TaintState::clear_by_member). Recorded write-ahead (kind taint.clear); with no records log, or when the record cannot be written, nothing is cleared (503). Scheduled (unattended) runs are never cleared (409); empty note 400; not tainted 409. Approving one action still never clears the taint; - a declined card tells the model to ask the member instead of retrying (shell_run and approval cards). Test support opens one undo store per manager (a store is locked while open). Tests: action_approvals_tests (+2). Workspace: 2253 -> 2255 passed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018wWZZ8GRVU9USh3kKQHsFe --- agent-sidecar/src/action_approvals.rs | 8 +- agent-sidecar/src/action_approvals_tests.rs | 145 ++++++++++++++++++-- agent-sidecar/src/hgw_support.rs | 46 ++++--- agent-sidecar/src/lib.rs | 107 +++++++++++++-- agent-sidecar/src/sessions.rs | 12 +- agent-sidecar/src/shell_run.rs | 2 +- 6 files changed, 272 insertions(+), 48 deletions(-) diff --git a/agent-sidecar/src/action_approvals.rs b/agent-sidecar/src/action_approvals.rs index 82b20c8..e2665fc 100644 --- a/agent-sidecar/src/action_approvals.rs +++ b/agent-sidecar/src/action_approvals.rs @@ -4,7 +4,8 @@ //! output, a web page, an MCP result), an effectful sidecar-hosted call (`fs_*`, `file_write`, //! `sheet_write`, the toolchain tools, `project_create`, capsules) is not refused for the rest of //! the conversation. It waits here as an approval card that citrate-core shows to the member -//! (`GET /sessions/:id/actions/pending`, decided with `POST /sessions/:id/actions/decide`): +//! (`GET /sessions/:id/approvals/pending`, decided with +//! `POST /sessions/:id/approvals/:aid/decide {allow, subject}`): //! //! - the card shows the tool, the exact arguments that will be used (`subject`, canonical JSON), //! a one-line summary, why it is gated (`hicReason`) and every source that tainted the session @@ -12,7 +13,8 @@ //! - a decision must carry the `subject` that was shown; anything else is refused and changes //! nothing; //! - an allow releases **that one call**, with those arguments. The session stays tainted, so -//! the next effectful call asks again (no approval is ever global); +//! the next effectful call asks again (no approval is ever global). Clearing the taint is a +//! separate, explicit, recorded member action (`POST /sessions/:id/taint/clear`); //! - a decline, a session stop, or no decision within [`ACTION_APPROVAL_TIMEOUT`] declines it. //! //! The queue exists only for a session opened with `hicAware: true` (core's promise that a @@ -235,7 +237,7 @@ impl ActionApprovals { break if a { Ok(()) } else { - Err("the member declined this action".to_string()) + Err("the member declined this action; ask the member what to do instead of retrying it".to_string()) }; } if stop.is_stopped() { diff --git a/agent-sidecar/src/action_approvals_tests.rs b/agent-sidecar/src/action_approvals_tests.rs index f1cb77a..fde4c8b 100644 --- a/agent-sidecar/src/action_approvals_tests.rs +++ b/agent-sidecar/src/action_approvals_tests.rs @@ -15,6 +15,8 @@ //! - Headless: `without_an_hic_aware_app_tainted_actions_are_declined_and_say_why`. //! - Routes: `the_action_routes_need_the_bearer_and_a_known_session`. //! - Records: `action_decisions_are_recorded_for_the_anchor`. +//! - Explicit clear (HGW-W1, #74): `taint_clear_is_an_explicit_recorded_member_action`. +//! - Ask, don't retry: `a_declined_action_tells_the_model_to_ask_not_retry`. #![cfg(unix)] @@ -181,7 +183,7 @@ async fn a_decision_must_carry_the_arguments_that_were_shown() { let (_, ac) = send( &app, "GET", - &format!("/sessions/{id}/actions/pending"), + &format!("/sessions/{id}/approvals/pending"), None, ) .await; @@ -192,12 +194,13 @@ async fn a_decision_must_carry_the_arguments_that_were_shown() { tokio::time::sleep(std::time::Duration::from_millis(10)).await; } assert!(card.is_object(), "no action card appeared"); - let url = format!("/sessions/{id}/actions/decide"); + let aid = card["id"].as_str().unwrap_or_default().to_string(); + let url = format!("/sessions/{id}/approvals/{aid}/decide"); let (st, _) = send( &app, "POST", &url, - Some(serde_json::json!({"id": card["id"], "allow": true, "subject": "{\"path\":\"/etc/x\"}"})), + Some(serde_json::json!({"allow": true, "subject": "{\"path\":\"/etc/x\"}"})), ) .await; assert_eq!(st, StatusCode::CONFLICT); @@ -205,18 +208,24 @@ async fn a_decision_must_carry_the_arguments_that_were_shown() { let (st, _) = send( &app, "POST", - &url, - Some(serde_json::json!({"id": "act-999", "allow": true, "subject": card["subject"]})), + &format!("/sessions/{id}/approvals/act-999/decide"), + Some(serde_json::json!({"allow": true, "subject": card["subject"]})), ) .await; assert_eq!(st, StatusCode::CONFLICT); - let (st, _) = send(&app, "POST", &url, Some(serde_json::json!({"id": 1}))).await; + let (st, _) = send( + &app, + "POST", + &url, + Some(serde_json::json!({"allow": "yes"})), + ) + .await; assert_eq!(st, StatusCode::BAD_REQUEST); let (st, _) = send( &app, "POST", &url, - Some(serde_json::json!({"id": card["id"], "allow": true, "subject": card["subject"]})), + Some(serde_json::json!({"allow": true, "subject": card["subject"]})), ) .await; assert_eq!(st, StatusCode::OK); @@ -259,7 +268,7 @@ async fn without_an_hic_aware_app_tainted_actions_are_declined_and_say_why() { let (st, body) = send( &app, "GET", - &format!("/sessions/{id}/actions/pending"), + &format!("/sessions/{id}/approvals/pending"), None, ) .await; @@ -280,20 +289,20 @@ async fn the_action_routes_need_the_bearer_and_a_known_session() { .clone() .oneshot( Request::builder() - .uri(format!("/sessions/{id}/actions/pending")) + .uri(format!("/sessions/{id}/approvals/pending")) .body(Body::empty()) .expect("req"), ) .await .expect("resp"); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); - let (st, _) = send(&app, "GET", "/sessions/nope/actions/pending", None).await; + let (st, _) = send(&app, "GET", "/sessions/nope/approvals/pending", None).await; assert_eq!(st, StatusCode::NOT_FOUND); let (st, _) = send( &app, "POST", - "/sessions/nope/actions/decide", - Some(serde_json::json!({"id": "act-1", "allow": true, "subject": "{}"})), + "/sessions/nope/approvals/act-1/decide", + Some(serde_json::json!({"allow": true, "subject": "{}"})), ) .await; assert_eq!(st, StatusCode::NOT_FOUND); @@ -348,3 +357,115 @@ async fn action_decisions_are_recorded_for_the_anchor() { assert_eq!(outcome, Some(Outcome::Completed)); assert_eq!(decisions[1].1, Decision::Denied); } + +/// HGW-W1 (#74): a member can clear a session's taint, but only as an explicit, recorded action: +/// not for a scheduled run, not with an empty note, and not when the decision cannot be recorded +/// (write-ahead, fail closed). After a clear, effectful calls run without a card again. +#[tokio::test(flavor = "multi_thread")] +async fn taint_clear_is_an_explicit_recorded_member_action() { + use citrate_agent_records::{Decision, DecisionLog, Entry, LogConfig}; + let fx = Fx::new("taint-clear"); + let f = fx.proj().join("after-clear.txt"); + // Without a records log, nothing is cleared. + let mgr = Arc::new(manager_with(&fx, vec![], Some(shell_open()))); + let id = mgr.create(create_req(fx.rw_doc(), true)).expect("session"); + let s = mgr.get(&id).expect("session"); + s.taint().taint("shell_run", "output"); + let app = app_for(mgr.clone()); + let url = format!("/sessions/{id}/taint/clear"); + let (st, _) = send( + &app, + "POST", + &url, + Some(serde_json::json!({"note": "I read that output"})), + ) + .await; + assert_eq!(st, StatusCode::SERVICE_UNAVAILABLE); + assert!(s.taint().is_tainted()); + // With records: an empty note is refused, a real one clears and is recorded. + let dir = fx.base.join("records"); + let (log, _) = DecisionLog::open(&dir, LogConfig::default()).expect("log"); + let turns = vec![ + AssistantTurn::tools(vec![fs_write("c1", &f, "F")]), + AssistantTurn::text("ok"), + ]; + let mgr = Arc::new(manager_with(&fx, turns, Some(shell_open())).with_records(Arc::new(log))); + let id = mgr.create(create_req(fx.rw_doc(), true)).expect("session"); + let s = mgr.get(&id).expect("session"); + s.taint().taint("shell_run", "output"); + let app = app_for(mgr.clone()); + let url = format!("/sessions/{id}/taint/clear"); + let (st, _) = send(&app, "POST", &url, Some(serde_json::json!({"note": " "}))).await; + assert_eq!(st, StatusCode::BAD_REQUEST); + let (st, body) = send( + &app, + "POST", + &url, + Some(serde_json::json!({"note": "I read that output"})), + ) + .await; + assert_eq!(st, StatusCode::OK, "{body}"); + assert_eq!(body["cleared"]["source"], "shell_run"); + assert!(!s.taint().is_tainted()); + let (st, _) = send( + &app, + "POST", + &url, + Some(serde_json::json!({"note": "again"})), + ) + .await; + assert_eq!(st, StatusCode::CONFLICT, "nothing to clear"); + // The next write runs without a card. + mgr.send(&id, "go".into(), None).expect("send"); + wait_done(&s).await; + assert_eq!(result_of(&s, "c1").await["status"], "ok"); + assert!(f.exists()); + let recs = citrate_agent_records::read::page(&dir, None, 10_000).expect("records"); + assert!(recs.iter().any(|r| matches!(&r.record.entry, + Entry::Decision(d) if d.kind == "taint.clear" && d.decision == Decision::Approved && d.subject.contains("shell_run")))); + // A scheduled run's taint is never cleared. + let mut req = create_req(fx.rw_doc(), true); + req.unattended = true; + let id = mgr.create(req).expect("session"); + let (st, _) = send( + &app, + "POST", + &format!("/sessions/{id}/taint/clear"), + Some(serde_json::json!({"note": "please"})), + ) + .await; + assert_eq!(st, StatusCode::CONFLICT); + assert!(mgr.get(&id).expect("session").taint().is_tainted()); +} + +/// HGW-W1 (#74 AC3): a declined action tells the model to ask the member, not to retry. +#[tokio::test(flavor = "multi_thread")] +async fn a_declined_action_tells_the_model_to_ask_not_retry() { + let fx = Fx::new("taint-decline-hint"); + let proj = fx.proj(); + let turns = vec![ + AssistantTurn::tools(vec![shell("c1", &["echo", "x"], &proj)]), + AssistantTurn::tools(vec![fs_write("c2", &proj.join("a.txt"), "A")]), + AssistantTurn::tools(vec![shell("c3", &["echo", "y"], &proj)]), + AssistantTurn::text("ok"), + ]; + let mgr = Arc::new(manager_with(&fx, turns, Some(shell_open()))); + let id = mgr.create(create_req(fx.rw_doc(), true)).expect("session"); + let s = mgr.get(&id).expect("session"); + let app = app_for(mgr.clone()); + mgr.send(&id, "go".into(), None).expect("send"); + // Allow the first command; decline the write and the second command. + let mut n = 0; + member_answers(&app, &s, &id, move |_, _| { + n += 1; + n == 1 + }) + .await; + for c in ["c2", "c3"] { + let r = result_of(&s, c).await; + assert_eq!(r["status"], "denied", "{r}"); + let msg = r["content"].as_str().unwrap_or_default(); + assert!(msg.contains("ask the member"), "{msg}"); + assert!(msg.contains("instead of retrying"), "{msg}"); + } +} diff --git a/agent-sidecar/src/hgw_support.rs b/agent-sidecar/src/hgw_support.rs index cf02da6..ad6ec9f 100644 --- a/agent-sidecar/src/hgw_support.rs +++ b/agent-sidecar/src/hgw_support.rs @@ -30,8 +30,8 @@ pub(crate) fn now() -> u64 { .unwrap_or(0) } -/// `base/home/proj` (granted read + write), `base/home/other` (not granted) and the undo store -/// at `base/ckpt` (outside the home). +/// `base/home/proj` (granted read + write), `base/home/other` (not granted); undo stores go in +/// `base/ckpt-` (outside the home). pub(crate) struct Fx { pub base: PathBuf, } @@ -44,7 +44,7 @@ impl Fx { N.fetch_add(1, Ordering::SeqCst) )); let _ = std::fs::remove_dir_all(&base); - for d in ["home/proj/src", "home/other", "ckpt"] { + for d in ["home/proj/src", "home/other"] { std::fs::create_dir_all(base.join(d)).expect("fixture dirs"); } Fx { @@ -112,8 +112,11 @@ pub(crate) fn manager_with( shell: Option, ) -> sessions::SessionManager { let script = Arc::new(Script(Mutex::new(turns))); - let store = - Arc::new(CheckpointStore::open(&fx.base.join("ckpt"), Config::default()).expect("store")); + // One store per manager: a store is locked while it is open. + let dir = fx + .base + .join(format!("ckpt-{}", N.fetch_add(1, Ordering::SeqCst))); + let store = Arc::new(CheckpointStore::open(&dir, Config::default()).expect("store")); let mut mgr = sessions::SessionManager::new( Arc::new(move |_ep: &sessions::LlmEndpoint| script.clone() as Arc), Duration::from_secs(5), @@ -251,28 +254,37 @@ pub(crate) async fn member_answers( app: &axum::Router, s: &sessions::Session, id: &str, - decide: impl Fn(&'static str, &serde_json::Value) -> bool, + mut decide: impl FnMut(&'static str, &serde_json::Value) -> bool, ) -> Vec { let mut out = Vec::new(); for _ in 0..3000 { for route in ["shell", "actions"] { + // Labels: `shell` = the shell_run cards, `actions` = the session's approval cards. + let base = if route == "shell" { + "shell" + } else { + "approvals" + }; let (st, body) = - send(app, "GET", &format!("/sessions/{id}/{route}/pending"), None).await; + send(app, "GET", &format!("/sessions/{id}/{base}/pending"), None).await; assert_eq!(st, StatusCode::OK, "{route}: {body}"); for card in body["pending"].as_array().cloned().unwrap_or_default() { let allow = decide(route, &card); - let decision = if route == "shell" { - serde_json::json!({"id": card["id"], "allow": allow, "argv": card["argv"], "cwd": card["cwd"]}) + let (url, decision) = if route == "shell" { + ( + format!("/sessions/{id}/shell/decide"), + serde_json::json!({"id": card["id"], "allow": allow, "argv": card["argv"], "cwd": card["cwd"]}), + ) } else { - serde_json::json!({"id": card["id"], "allow": allow, "subject": card["subject"]}) + ( + format!( + "/sessions/{id}/approvals/{}/decide", + card["id"].as_str().unwrap_or_default() + ), + serde_json::json!({"allow": allow, "subject": card["subject"]}), + ) }; - let (st, b) = send( - app, - "POST", - &format!("/sessions/{id}/{route}/decide"), - Some(decision), - ) - .await; + let (st, b) = send(app, "POST", &url, Some(decision)).await; assert_eq!(st, StatusCode::OK, "{route} decide: {b}"); out.push(Answered { route, card, allow }); } diff --git a/agent-sidecar/src/lib.rs b/agent-sidecar/src/lib.rs index 6466106..6251a21 100644 --- a/agent-sidecar/src/lib.rs +++ b/agent-sidecar/src/lib.rs @@ -300,8 +300,9 @@ pub fn app(state: Arc) -> Router { .route("/sessions/:id/shell/decide", post(shell_decide)) .route("/sessions/:id/mcp/pending", get(mcp_pending)) .route("/sessions/:id/mcp/decide", post(mcp_decide)) - .route("/sessions/:id/actions/pending", get(action_pending)) - .route("/sessions/:id/actions/decide", post(action_decide)) + .route("/sessions/:id/approvals/pending", get(action_pending)) + .route("/sessions/:id/approvals/:aid/decide", post(action_decide)) + .route("/sessions/:id/taint/clear", post(taint_clear)) // HUP-S1.4 — tracks + briefs (the interview every client shares). .route("/tracks", get(tracks)) .route("/briefs", post(create_brief)) @@ -1050,8 +1051,9 @@ async fn mcp_pending( Ok(Json(serde_json::json!({ "pending": pending }))) } -/// HGW-1: `GET /sessions/:id/actions/pending`: the action cards waiting for the member (tainted -/// effectful sidecar calls in an HIC-aware session). A session without them has none. +/// HGW-1: `GET /sessions/:id/approvals/pending`: the approval cards waiting for the member +/// (tainted effectful sidecar calls in an HIC-aware session). A session without them has none. +/// `shell_run` keeps its own cards on `/sessions/:id/shell/pending`. async fn action_pending( headers: HeaderMap, State(st): State>, @@ -1070,18 +1072,17 @@ async fn action_pending( #[derive(Deserialize)] struct ActionDecideReq { - id: String, allow: bool, subject: String, } -/// HGW-1: `POST /sessions/:id/actions/decide {id, allow, subject}`: the member's decision on one -/// action card. `subject` must be the arguments that were shown; otherwise 409 and nothing is -/// decided. Recorded like the other HIC decisions (write-ahead for an allow). +/// HGW-1: `POST /sessions/:id/approvals/:aid/decide {allow, subject}`: the member's decision on +/// one approval card. `subject` must be the arguments that were shown; otherwise 409 and nothing +/// is decided. Recorded like the other HIC decisions (write-ahead for an allow). async fn action_decide( headers: HeaderMap, State(st): State>, - Path(id): Path, + Path((id, aid)): Path<(String, String)>, body: axum::body::Bytes, ) -> Result, JsonErr> { if !authorized(&headers, &st.bearer) { @@ -1092,9 +1093,9 @@ async fn action_decide( .get(&id) .ok_or_else(|| json_err(StatusCode::NOT_FOUND, "no such session"))?; let req: ActionDecideReq = serde_json::from_slice(&body) - .map_err(|_| json_err(StatusCode::BAD_REQUEST, "expected {id, allow, subject}"))?; + .map_err(|_| json_err(StatusCode::BAD_REQUEST, "expected {allow, subject}"))?; let card = session - .action_waiting(&req.id) + .action_waiting(&aid) .filter(|c| c.subject == req.subject) .ok_or_else(|| { json_err( @@ -1113,7 +1114,7 @@ async fn action_decide( "the member decided on the exact action shown, in a session that read untrusted content", vec![citrate_agent_records::EvidenceRef { kind: "sidecar_tool_call".to_string(), - uri: format!("sidecar:sessions/{id}/actions/{}", req.id), + uri: format!("sidecar:sessions/{id}/approvals/{aid}"), digest: None, }], ) @@ -1125,7 +1126,7 @@ async fn action_decide( }, _ => None, }; - match session.action_decide(&req.id, req.allow, &req.subject) { + match session.action_decide(&aid, req.allow, &req.subject) { Ok(()) => { if let Some(log) = &log { match allow_seq { @@ -1162,6 +1163,86 @@ async fn action_decide( } } +#[derive(Deserialize)] +struct TaintClearReq { + note: String, +} + +/// HGW-W1 (#74): `POST /sessions/:id/taint/clear {note}`: the member clears the session's taint +/// (`TaintState::clear_by_member`), from an explicit card in the app. Refused (409) for a +/// scheduled run, whose taint is never cleared, and when nothing is tainted; 400 for an empty +/// note. The decision is recorded write-ahead, and without a records log (or when the record +/// cannot be written) nothing is cleared (503). Approving one action never clears the taint. +async fn taint_clear( + headers: HeaderMap, + State(st): State>, + Path(id): Path, + body: axum::body::Bytes, +) -> Result, JsonErr> { + if !authorized(&headers, &st.bearer) { + return Err(json_err(StatusCode::UNAUTHORIZED, "unauthorized")); + } + let session = st + .sessions + .get(&id) + .ok_or_else(|| json_err(StatusCode::NOT_FOUND, "no such session"))?; + let req: TaintClearReq = serde_json::from_slice(&body) + .map_err(|_| json_err(StatusCode::BAD_REQUEST, "expected {note}"))?; + let ack = citrate_agent_loop::MemberClear::new(req.note.clone()).ok_or_else(|| { + json_err( + StatusCode::BAD_REQUEST, + "the note is empty; nothing was cleared", + ) + })?; + if session.is_unattended() { + return Err(json_err( + StatusCode::CONFLICT, + "a scheduled run's taint is never cleared", + )); + } + let sources = session.taint().sources().unwrap_or_default(); + if !session.taint().is_tainted() { + return Err(json_err( + StatusCode::CONFLICT, + "this session is not tainted", + )); + } + let log = st.sessions.records().ok_or_else(|| { + json_err( + StatusCode::SERVICE_UNAVAILABLE, + "clearing a taint must be recorded, and no decision records are configured; nothing was cleared", + ) + })?; + let seq = hic_records::record_member_decision( + &log, + "taint.clear", + &format!( + "clear the untrusted-content taint of session {id} (sources: {})", + sources.join(", ") + ), + true, + &req.note, + vec![citrate_agent_records::EvidenceRef { + kind: "taint_clear".to_string(), + uri: format!("sidecar:sessions/{id}/taint"), + digest: None, + }], + ) + .map_err(|e| json_err(StatusCode::SERVICE_UNAVAILABLE, &e))?; + let cleared = session.taint().clear_by_member(ack); + hic_records::record_outcome( + &log, + seq, + citrate_agent_records::Outcome::Completed, + "the session's taint was cleared", + ); + Ok(Json(serde_json::json!({ + "ok": true, + "cleared": cleared.map(|r| serde_json::json!({"source": r.source, "reason": r.reason})), + "sources": sources, + }))) +} + #[derive(Deserialize)] struct McpDecideReq { id: String, diff --git a/agent-sidecar/src/sessions.rs b/agent-sidecar/src/sessions.rs index d83ff33..37cc9f9 100644 --- a/agent-sidecar/src/sessions.rs +++ b/agent-sidecar/src/sessions.rs @@ -67,8 +67,8 @@ //! `/shell` routes and runs only in the OS sandbox. The name is reserved while it is on. //! - HGW-1 (owner decision 2026-10-15): taint escalates to HIC approval instead of ending the //! conversation. In a session opened with `hicAware: true`, a tainted effectful sidecar-hosted -//! call waits for the member on the session's action cards (`/actions` routes, -//! [`crate::action_approvals`]); `shell_run`, MCP and browser calls use their own cards, which +//! call waits for the member on the session's approval cards (`/sessions/:id/approvals` +//! routes, [`crate::action_approvals`]); `shell_run`, MCP and browser calls use their own cards, which //! 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`]. @@ -555,6 +555,8 @@ pub struct Session { mcp_approvals: Option>, /// HGW-1: the cards for tainted effectful sidecar calls (present only for an HIC-aware app). actions: Option>, + /// HUP-S10.3: a scheduled run nobody watches; its taint is never cleared. + unattended: 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)] @@ -603,6 +605,11 @@ impl Session { a.decide(id, allow, subject).map_err(SessionError::Invalid) } + /// HUP-S10.3: whether this is a scheduled run nobody watches (its taint is never cleared). + pub fn is_unattended(&self) -> bool { + self.unattended + } + /// 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> { @@ -1836,6 +1843,7 @@ impl SessionManager { mcp_offered, mcp_approvals, actions, + unattended: req.unattended, #[cfg(test)] event_hook: Mutex::new(None), }); diff --git a/agent-sidecar/src/shell_run.rs b/agent-sidecar/src/shell_run.rs index 2fbca56..ebccc65 100644 --- a/agent-sidecar/src/shell_run.rs +++ b/agent-sidecar/src/shell_run.rs @@ -270,7 +270,7 @@ impl ShellApprovals { break if a { Ok(()) } else { - Err("the member declined this command".to_string()) + Err("the member declined this command; ask the member what to do instead of retrying it".to_string()) }; } if stopped() {