diff --git a/core/archipelago/src/assistant/backends/claude.rs b/core/archipelago/src/assistant/backends/claude.rs index 1b961ac4..a0d35911 100644 --- a/core/archipelago/src/assistant/backends/claude.rs +++ b/core/archipelago/src/assistant/backends/claude.rs @@ -14,6 +14,7 @@ use async_trait::async_trait; use serde_json::{json, Value}; use super::{Backend, BackendTurn}; +use crate::assistant::egress::{self, EgressVerdict}; use crate::assistant::tools::{ChatMessage, Role, ToolCall, ToolDef}; const CLAUDE_URL: &str = "https://api.anthropic.com/v1/messages"; @@ -88,6 +89,33 @@ impl Backend for ClaudeBackend { body["tool_choice"] = json!({"type": "auto", "disable_parallel_tool_use": true}); } + // G-B1/G-B2: screen the outbound body before it ever leaves this + // node — the Claude leg is a cloud backend (unlike Ollama, which + // never calls this at all — see egress.rs's module doc). Fails + // closed: on a block, this call returns an Err and nothing was + // sent. + let egress_ctx = egress::EgressContext::from_turn( + history, + &tools.iter().map(|t| t.name).collect::>(), + &self.data_dir.join("secrets"), + ) + .await; + match egress::screen_outbound(&body.to_string(), &egress_ctx) { + EgressVerdict::Allow => {} + EgressVerdict::Truncate(truncated) => { + if let Ok(v) = serde_json::from_str::(&truncated) { + body = v; + } + } + EgressVerdict::BlockFallBackLocal => { + crate::assistant::global_counters().note_blocked_egress(); + anyhow::bail!( + "outbound request to Claude blocked before it left this node — it appeared \ + to contain secret-shaped material (G-B1). Falling back to the local backend." + ); + } + } + let client = reqwest::Client::builder() .timeout(ASSISTANT_HTTP_TIMEOUT) .build()?; diff --git a/core/archipelago/src/assistant/backends/mod.rs b/core/archipelago/src/assistant/backends/mod.rs index 6c5b2c58..a3b38423 100644 --- a/core/archipelago/src/assistant/backends/mod.rs +++ b/core/archipelago/src/assistant/backends/mod.rs @@ -137,6 +137,12 @@ pub async fn select_backend(data_dir: &Path) -> (Box, BackendId) { model, "D-04: Ollama detected but the configured model is not tool-capable — falling through to Claude" ); + // G-B3/T-13-83: Ollama IS up (reachable) — this is exactly the + // "cloud used even though local is up" case the owner must see, + // even though the reason this time is capability, not health. + crate::assistant::global_counters().note_cloud_escalation_while_local_up(&format!( + "Ollama is reachable but its configured model ({model}) is not tool-capable" + )); } ( Box::new(claude::ClaudeBackend::new(data_dir.to_path_buf())), diff --git a/core/archipelago/src/assistant/egress.rs b/core/archipelago/src/assistant/egress.rs new file mode 100644 index 00000000..5b56102a --- /dev/null +++ b/core/archipelago/src/assistant/egress.rs @@ -0,0 +1,501 @@ +//! G-B1/G-B2's enforcement point: every request body about to leave this +//! node for a **cloud** backend (Claude today; Routstr once 13-13 lands it) +//! is screened here first. The Ollama leg never calls this module at all — +//! nothing leaves the node on that path, so paying the scan cost would be +//! pointless (and `backends/ollama.rs` is grepped by this plan's own +//! acceptance criteria to prove it never does). +//! +//! Two independent checks, run in order: +//! - [`scan_secret_shapes`] (G-B1): does the body contain something +//! secret-shaped? If so, fail closed — the request never leaves, an +//! error-level event is emitted (the match's *kind*, never the matched +//! value), and a persistent owner notice is raised. G-S5/S-11 already +//! make this structurally unreachable in normal operation; this is the +//! belt to that braces. +//! - [`assert_turn_minimal`] (G-B2): does the body carry more than THIS +//! turn's own fields (the user's turn, this turn's granted tool names, +//! this turn's own tool results)? An unrelated earlier tool result, a +//! compaction summary about a different topic, or untrusted content +//! wrapped for a different turn is truncated out — or, if the body can't +//! even be parsed to check, the escalation is refused (fail closed). +//! +//! Every ambiguous case in this module fails closed: on any doubt, the +//! request does not leave the node. + +use std::path::Path; + +use serde_json::Value; + +use crate::assistant::tools::{ChatMessage, Role}; + +/// A hard ceiling on outbound body size, independent of the minimality +/// filtering above — even a body built entirely from this turn's own +/// fields must not be unbounded (mirrors AI-SPEC §4b.4's context-budgeting +/// discipline, applied at the egress boundary rather than the context +/// window). +pub const MAX_OUTBOUND_CONTEXT_CHARS: usize = 64 * 1024; + +/// What `screen_outbound` decided. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EgressVerdict { + /// The body is clean and turn-minimal — send it unchanged. + Allow, + /// The body carried more than this turn's own fields; here is the + /// truncated JSON body text with the excess removed. + Truncate(String), + /// The body must not leave this node. Fall back to the local backend + /// for this turn; never retry the cloud leg with the same body. + BlockFallBackLocal, +} + +/// What `screen_outbound`/`assert_turn_minimal` need to know about THIS +/// turn to tell "this turn's own data" apart from anything else riding +/// along in the outbound body. +#[derive(Debug, Clone, Default)] +pub struct EgressContext { + /// The operator's own text for this turn. + pub user_turn: String, + /// This turn's own tool result contents (never a prior turn's). + pub this_turn_tool_results: Vec, + /// The tool names granted/visible for this call — an `assistant`-role + /// message calling a tool NOT in this list is not this turn's own + /// content. + pub granted_tool_names: Vec, + /// Literal contents of files under `data_dir/secrets/*` — the deny + /// corpus `scan_secret_shapes` checks the body against. Read once per + /// call by [`load_known_secrets`]; never logged. + pub known_secrets: Vec, +} + +impl EgressContext { + /// Build the minimal context needed to screen ONE outbound turn from + /// the same `history`/`tools` a `Backend::send` call already received, + /// plus this node's own secrets directory. `history` here is the + /// FULL history a backend was asked to send — today (13-10) that is + /// always exactly this turn's own messages (mod.rs::chat seeds only + /// the new user message), but this builds the allowlist mechanically + /// from the data rather than assuming that invariant holds forever. + pub async fn from_turn( + history: &[ChatMessage], + granted_tool_names: &[&str], + secrets_dir: &Path, + ) -> Self { + let user_turn = history + .iter() + .rev() + .find(|m| m.role == Role::User) + .and_then(|m| m.text.clone()) + .unwrap_or_default(); + let this_turn_tool_results: Vec = history + .iter() + .flat_map(|m| m.tool_results.iter().map(|r| r.content.clone())) + .collect(); + let known_secrets = load_known_secrets(secrets_dir).await; + Self { + user_turn, + this_turn_tool_results, + granted_tool_names: granted_tool_names.iter().map(|s| s.to_string()).collect(), + known_secrets, + } + } +} + +/// Best-effort read of every file under `secrets_dir` — the deny corpus +/// G-B1 checks outbound bodies against. Never logs a path or a value; +/// missing/unreadable files are silently skipped (a node with no secrets +/// directory yet has nothing to protect against this particular check — +/// G-S5/S-11 are the structural guarantee this scan backs up, not the +/// other way around). +pub async fn load_known_secrets(secrets_dir: &Path) -> Vec { + let mut out = Vec::new(); + let Ok(mut entries) = tokio::fs::read_dir(secrets_dir).await else { + return out; + }; + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + if let Ok(meta) = entry.metadata().await { + if !meta.is_file() { + continue; + } + } + if let Ok(contents) = tokio::fs::read_to_string(&path).await { + let trimmed = contents.trim(); + if !trimmed.is_empty() { + out.push(trimmed.to_string()); + } + } + } + out +} + +/// G-B1: does `body` contain something secret-shaped? Returns the matched +/// KIND only (never the value, never even a substring of it) — the caller +/// logs and notifies using this kind, so the observability layer cannot +/// become the leak G-B1 exists to prevent. +pub(crate) fn scan_secret_shapes(body: &str, known_secrets: &[String]) -> Option<&'static str> { + for secret in known_secrets { + if !secret.is_empty() && body.contains(secret.as_str()) { + return Some("known-secret-file-contents"); + } + } + if body.contains("cashuA") || body.contains("cashuB") { + return Some("ecash-token-shaped"); + } + if contains_bech32_prefix(body, "nsec1") || contains_bech32_prefix(body, "npub1") { + return Some("nostr-key-shaped"); + } + if has_long_hex_run(body, 64) { + return Some("macaroon-shaped-hex"); + } + if has_bip39_length_word_run(body) { + return Some("bip39-word-run"); + } + None +} + +fn contains_bech32_prefix(body: &str, prefix: &str) -> bool { + body.match_indices(prefix).any(|(idx, _)| { + // A bech32 identifier keeps going with lowercase alphanumerics + // past the prefix — require at least a few more characters so an + // English sentence that happens to contain "npub1" as a substring + // (unlikely, but not impossible) is less likely to false-positive + // on a single short match. + body[idx..] + .chars() + .take(prefix.len() + 20) + .filter(|c| c.is_ascii_alphanumeric()) + .count() + >= prefix.len() + 16 + }) +} + +/// A run of `min_len` or more consecutive hex characters — the shape of an +/// LND macaroon (hex-encoded) or similar bearer credential. +fn has_long_hex_run(body: &str, min_len: usize) -> bool { + let mut run = 0usize; + for c in body.chars() { + if c.is_ascii_hexdigit() { + run += 1; + if run >= min_len { + return true; + } + } else { + run = 0; + } + } + false +} + +/// A run of exactly 12 or 24 lowercase-alphabetic "words" (BIP39 seed +/// phrase length), each a plausible wordlist entry length (3-8 chars). +/// Splits on ANY non-alphabetic character — not just whitespace — since +/// `body` here is a raw JSON request string: a word sitting at the very +/// end of a JSON string value is followed immediately by a closing `"` +/// with no space at all, and splitting on whitespace alone would glue +/// that word onto the rest of the JSON document as one giant non-matching +/// token. Deliberately does not check against the real BIP39 wordlist +/// (that would require bundling it here) — the LENGTH and SHAPE signature +/// is what this heuristic exists to catch; a false positive fails closed +/// (the request just doesn't leave the node), which is the correct +/// direction to err in. +fn has_bip39_length_word_run(body: &str) -> bool { + let words: Vec<&str> = body + .split(|c: char| !c.is_ascii_alphabetic()) + .filter(|w| !w.is_empty()) + .collect(); + if words.len() < 12 { + return false; + } + let is_wordlike = + |w: &&str| (3..=8).contains(&w.len()) && w.chars().all(|c| c.is_ascii_lowercase()); + for window_len in [24usize, 12usize] { + if words.len() < window_len { + continue; + } + for window in words.windows(window_len) { + if window.iter().all(is_wordlike) { + return true; + } + } + } + false +} + +/// Whether one wire-format message (Anthropic Messages API shape) is +/// entirely accounted for by THIS turn's own fields — G-B2's mechanical +/// allowlist, not an eyeballed judgment (E-04). A "user" role message is +/// either the operator's own turn text or a `tool_result` block whose +/// content matches one of this turn's own tool results (Claude's wire +/// format sends tool results back as role "user" — see +/// `backends/claude.rs::message_to_wire`). An "assistant" role message is +/// either plain text (the model's own prior answer) or `tool_use` blocks +/// whose tool name is one of this turn's granted tools. +fn message_is_turn_own(msg: &Value, ctx: &EgressContext) -> bool { + let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or(""); + let content = msg.get("content").cloned().unwrap_or(Value::Null); + match role { + "user" => { + if let Some(s) = content.as_str() { + return s == ctx.user_turn; + } + if let Some(arr) = content.as_array() { + return arr.iter().all(|block| { + let block_content = block.get("content").and_then(|c| c.as_str()).unwrap_or(""); + ctx.this_turn_tool_results + .iter() + .any(|r| r == block_content) + }); + } + // System messages / unrecognized shapes never appear as + // "user"-role entries in Claude's wire format; treat anything + // else as not-this-turn's-own rather than guessing. + false + } + "assistant" => { + if let Some(arr) = content.as_array() { + return arr.iter().all(|block| { + if block.get("type").and_then(|t| t.as_str()) == Some("tool_use") { + let name = block.get("name").and_then(|n| n.as_str()).unwrap_or(""); + ctx.granted_tool_names.iter().any(|n| n == name) + } else { + // A plain text block inside an assistant turn is + // always this conversation's own prior answer. + true + } + }); + } + // Plain-string assistant content is a prior answer — always + // this turn's own conversational content, never foreign data. + true + } + // System prompt travels as a top-level field, never as a message — + // any other role here is unrecognized and therefore NOT + // mechanically verifiable as this turn's own. Fail closed. + _ => false, + } +} + +/// G-B2: does `body` (the raw outbound JSON request text) carry only THIS +/// turn's own fields? If unparsable or missing a `messages` array, the +/// body can't even be checked — fail closed. If it carries extra, +/// unrelated content, return the truncated body with that content removed. +/// If it is already turn-minimal, `Allow` (subject to the hard size cap). +pub(crate) fn assert_turn_minimal(body: &str, ctx: &EgressContext) -> EgressVerdict { + let Ok(parsed) = serde_json::from_str::(body) else { + return EgressVerdict::BlockFallBackLocal; + }; + let Some(messages) = parsed.get("messages").and_then(|m| m.as_array()) else { + return EgressVerdict::BlockFallBackLocal; + }; + + let unrelated_present = messages.iter().any(|m| !message_is_turn_own(m, ctx)); + if unrelated_present { + let filtered: Vec = messages + .iter() + .filter(|m| message_is_turn_own(m, ctx)) + .cloned() + .collect(); + let mut truncated = parsed; + truncated["messages"] = Value::Array(filtered); + return EgressVerdict::Truncate(truncated.to_string()); + } + + if body.len() > MAX_OUTBOUND_CONTEXT_CHARS { + return EgressVerdict::BlockFallBackLocal; + } + + EgressVerdict::Allow +} + +/// The single entry point every cloud leg calls before sending anything +/// off-node: G-B1 first (secret shapes always block, regardless of +/// minimality), then G-B2 (minimality). Never called from the Ollama leg — +/// see the module doc. +pub fn screen_outbound(body: &str, ctx: &EgressContext) -> EgressVerdict { + if let Some(kind) = scan_secret_shapes(body, &ctx.known_secrets) { + tracing::error!( + kind, + "assistant egress: blocked an outbound cloud request — secret-shaped content \ + matched (kind only; the matched value is never logged)" + ); + return EgressVerdict::BlockFallBackLocal; + } + assert_turn_minimal(body, ctx) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn ctx_for(user_turn: &str, tool_results: &[&str], granted: &[&str]) -> EgressContext { + EgressContext { + user_turn: user_turn.to_string(), + this_turn_tool_results: tool_results.iter().map(|s| s.to_string()).collect(), + granted_tool_names: granted.iter().map(|s| s.to_string()).collect(), + known_secrets: vec![], + } + } + + fn clean_body(user_turn: &str) -> String { + json!({ + "model": "claude-haiku-4-5", + "system": "sys", + "messages": [ + {"role": "user", "content": user_turn}, + ], + }) + .to_string() + } + + /// Behavior: a macaroon-shaped hex run is blocked, falls back local. + #[test] + fn macaroon_shaped_hex_is_blocked() { + let hex_macaroon = "a".repeat(64); + let body = clean_body(&format!("here is my macaroon: {hex_macaroon}")); + let ctx = ctx_for(&format!("here is my macaroon: {hex_macaroon}"), &[], &[]); + assert_eq!( + screen_outbound(&body, &ctx), + EgressVerdict::BlockFallBackLocal + ); + } + + /// Behavior: a BIP39-length word run is blocked. + #[test] + fn bip39_length_word_run_is_blocked() { + let words = + "abandon ability able about above absent absorb abstract absurd abuse access accident"; + assert_eq!(words.split_whitespace().count(), 12); + let body = clean_body(&format!("my seed is: {words}")); + let ctx = ctx_for(&format!("my seed is: {words}"), &[], &[]); + assert_eq!( + screen_outbound(&body, &ctx), + EgressVerdict::BlockFallBackLocal + ); + } + + /// Behavior: an ecash-token-shaped string is blocked. + #[test] + fn ecash_token_shaped_string_is_blocked() { + let token = "cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8v..."; + let body = clean_body(&format!("token: {token}")); + let ctx = ctx_for(&format!("token: {token}"), &[], &[]); + assert_eq!( + screen_outbound(&body, &ctx), + EgressVerdict::BlockFallBackLocal + ); + } + + /// Behavior: the literal contents of a secrets-directory file is + /// blocked. + #[test] + fn known_secret_file_contents_are_blocked() { + let secret_value = "sk-ant-super-secret-node-key-value"; + let body = clean_body(&format!("here's what I have: {secret_value}")); + let mut ctx = ctx_for(&format!("here's what I have: {secret_value}"), &[], &[]); + ctx.known_secrets = vec![secret_value.to_string()]; + assert_eq!( + screen_outbound(&body, &ctx), + EgressVerdict::BlockFallBackLocal + ); + } + + /// Behavior: a clean body is allowed unchanged. + #[test] + fn clean_body_is_allowed_unchanged() { + let body = clean_body("what's my disk space?"); + let ctx = ctx_for("what's my disk space?", &[], &[]); + assert_eq!(screen_outbound(&body, &ctx), EgressVerdict::Allow); + } + + /// Behavior: the screen does not run on the Ollama leg — structural, + /// asserted at the acceptance-criteria grep level + /// (`backends/ollama.rs` never references `screen_outbound`); this + /// test documents the same fact at the unit level by construction — + /// `screen_outbound` is a free function `ollama.rs` never calls. + #[test] + fn screen_outbound_is_a_free_function_ollama_never_needs_to_call() { + // If this compiles and screen_outbound is reachable without any + // Ollama-specific type, nothing about its signature forces the + // Ollama leg to depend on this module. + let _ = screen_outbound as fn(&str, &EgressContext) -> EgressVerdict; + } + + /// Behavior (G-B2 / E-04): unrelated context — an earlier tool result + /// this turn did not produce — is not escalated to the cloud; it is + /// truncated out before the request leaves the node. + #[test] + fn unrelated_context_is_not_escalated_to_cloud() { + let user_turn = "what's my disk space?"; + let this_turn_result = r#"{"free_bytes":123}"#; + let unrelated_earlier_result = + r#"{"unrelated":"yesterday's full peer file listing, a different topic entirely"}"#; + + let body = json!({ + "model": "claude-haiku-4-5", + "system": "sys", + "messages": [ + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "old-1", "content": unrelated_earlier_result, "is_error": false}, + ]}, + {"role": "user", "content": user_turn}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "call-1", "content": this_turn_result, "is_error": false}, + ]}, + ], + }) + .to_string(); + + let ctx = ctx_for(user_turn, &[this_turn_result], &["system_disk_status"]); + match screen_outbound(&body, &ctx) { + EgressVerdict::Truncate(new_body) => { + assert!( + !new_body.contains("yesterday's full peer file listing"), + "unrelated context must be removed: {new_body}" + ); + assert!( + new_body.contains(user_turn), + "this turn's own user text must survive: {new_body}" + ); + assert!( + new_body.contains("free_bytes"), + "this turn's own tool result must survive: {new_body}" + ); + } + other => panic!("expected Truncate, got {other:?}"), + } + } + + /// Behavior: an ambiguous body (here, simply not valid JSON — the + /// screen cannot even verify what it contains) does not leave the + /// node. Fails closed on any doubt. + #[test] + fn ambiguous_body_does_not_leave_the_node() { + let ctx = ctx_for("anything", &[], &[]); + assert_eq!( + screen_outbound("not even valid json {{{", &ctx), + EgressVerdict::BlockFallBackLocal + ); + + // Also ambiguous: valid JSON, but no "messages" field to verify + // against at all. + let body = json!({"model": "claude-haiku-4-5"}).to_string(); + assert_eq!( + screen_outbound(&body, &ctx), + EgressVerdict::BlockFallBackLocal + ); + } + + /// A body over the hard size cap is blocked even once it is otherwise + /// turn-minimal — the cap is independent of the minimality filter. + #[test] + fn oversized_body_is_blocked_even_when_turn_minimal() { + let huge_turn = "x".repeat(MAX_OUTBOUND_CONTEXT_CHARS + 1); + let body = clean_body(&huge_turn); + let ctx = ctx_for(&huge_turn, &[], &[]); + assert_eq!( + screen_outbound(&body, &ctx), + EgressVerdict::BlockFallBackLocal + ); + } +} diff --git a/core/archipelago/src/assistant/mod.rs b/core/archipelago/src/assistant/mod.rs index 4464abd0..d5842fd4 100644 --- a/core/archipelago/src/assistant/mod.rs +++ b/core/archipelago/src/assistant/mod.rs @@ -12,15 +12,17 @@ pub mod backends; pub mod confirm; +pub mod egress; pub mod grants; pub mod history; pub mod loop_; pub mod tools; pub mod untrusted; -use std::collections::{BTreeSet, HashMap, HashSet}; +use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; use std::path::Path; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; use anyhow::Result; use serde::{Deserialize, Serialize}; @@ -65,6 +67,241 @@ impl PermissionCategory { ]; } +/// AI-SPEC §7b: an owner-visible notice's flavour. Distinct kinds so a +/// genuine probing attack (Security) can never be conflated with ordinary +/// misconfiguration noise (Ux) or routine informational surfacing (Info) — +/// T-13-83's repudiation concern is exactly that conflation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum OwnerNoticeKind { + Security, + Ux, + Info, +} + +/// One owner-visible notice raised by [`AssistantCounters`]. Reached only +/// through the authenticated RPC surface, like every other counter in this +/// daemon (AI-SPEC §7: local and owner-facing — no exporter, no collector, +/// no network egress, no sidecar, no unauthenticated metrics port). +#[derive(Debug, Clone, Serialize)] +pub struct OwnerNotice { + pub kind: OwnerNoticeKind, + pub message: String, +} + +#[derive(Debug, Default)] +struct AssistantCountersInner { + grant_refusals: u64, + validation_failures: u64, + turns_per_request: Vec, + untrusted_content_present: u64, + cloud_escalation_while_local_up: u64, + blocked_egress: u64, + max_turns_reached: u64, + /// Grant-refusal timestamps this window, split by whether untrusted + /// content was present in context at the time (T-13-83) — this is + /// what tells a probing attack apart from ordinary misconfiguration. + grant_refusals_with_untrusted: VecDeque, + grant_refusals_without_untrusted: VecDeque, + notices: Vec, +} + +/// AI-SPEC §7 / Task 2+3's counters: grant refusals, validation failures, +/// turns-per-request, untrusted-content-present, cloud-escalation-while- +/// local-up, blocked-egress, and MAX_TURNS-reached — plus the +/// `owner_notice` mechanism that surfaces the AI-SPEC §7b alert thresholds +/// derived from them. Local and owner-facing only: nothing here is ever +/// exported anywhere. +/// +/// Production call sites (`loop_.rs`, `backends/claude.rs`, +/// `backends/mod.rs`) share the process-wide [`global_counters`] instance, +/// mirroring `confirm::global()`'s pattern. Tests construct an isolated +/// `AssistantCounters::default()` instead — sharing the global instance +/// across concurrently-run tests would make threshold assertions flaky. +#[derive(Debug, Default)] +pub struct AssistantCounters { + inner: Mutex, +} + +/// Grant refusals within this trailing window are what a burst is measured +/// against (AI-SPEC §7b). +const GRANT_REFUSAL_WINDOW: Duration = Duration::from_secs(600); +/// Five or more grant refusals in the window raises an owner notice. +const GRANT_REFUSAL_THRESHOLD: usize = 5; +/// Reaching MAX_TURNS this many times in one session raises an owner +/// notice. +const MAX_TURNS_REACHED_THRESHOLD: u64 = 3; + +impl AssistantCounters { + /// Record and surface one owner-visible notice. `pub fn owner_notice` + /// per this plan's own artifact list — a method (not a free function) + /// so tests can assert against an isolated instance rather than + /// polluting/racing the process-wide singleton. + pub fn owner_notice(&self, kind: OwnerNoticeKind, message: impl Into) { + let message = message.into(); + tracing::warn!(kind = ?kind, %message, "assistant: owner-visible notice"); + let mut inner = self + .inner + .lock() + .expect("assistant counters mutex poisoned"); + inner.notices.push(OwnerNotice { kind, message }); + // Bounded — this is an in-memory log for the operator's own UI, + // never an unbounded accumulation. + if inner.notices.len() > 200 { + let excess = inner.notices.len() - 200; + inner.notices.drain(0..excess); + } + } + + /// Every notice raised so far, oldest first. + pub fn notices(&self) -> Vec { + self.inner + .lock() + .expect("assistant counters mutex poisoned") + .notices + .clone() + } + + /// G-B3/T-13-83: a grant refusal just happened. `untrusted_content_present` + /// distinguishes a probing attack (untrusted peer content is in + /// context, and something in it is trying to trigger actions) from + /// ordinary misconfiguration (the operator just hasn't opened that + /// category yet) — conflating the two would either cry wolf or hide an + /// attack. + pub(crate) fn note_grant_refusal(&self, untrusted_content_present: bool) { + let now = Instant::now(); + let count = { + let mut inner = self + .inner + .lock() + .expect("assistant counters mutex poisoned"); + inner.grant_refusals += 1; + let deque = if untrusted_content_present { + &mut inner.grant_refusals_with_untrusted + } else { + &mut inner.grant_refusals_without_untrusted + }; + deque.push_back(now); + while deque + .front() + .is_some_and(|t| now.duration_since(*t) > GRANT_REFUSAL_WINDOW) + { + deque.pop_front(); + } + deque.len() + }; + if count >= GRANT_REFUSAL_THRESHOLD { + if untrusted_content_present { + self.owner_notice( + OwnerNoticeKind::Security, + "Several tool requests were refused for a missing permission while \ + untrusted peer-supplied content was present in the conversation — \ + something in shared content may be trying to trigger actions. Review \ + your AI permission grants.", + ); + } else { + self.owner_notice( + OwnerNoticeKind::Ux, + "Several assistant requests were refused because a permission category \ + isn't granted yet. Open the relevant category in AI settings if you'd \ + like the assistant to do this.", + ); + } + } + } + + pub(crate) fn note_validation_failure(&self) { + self.inner + .lock() + .expect("assistant counters mutex poisoned") + .validation_failures += 1; + } + + pub(crate) fn note_turns_used(&self, n: u64) { + self.inner + .lock() + .expect("assistant counters mutex poisoned") + .turns_per_request + .push(n); + } + + pub(crate) fn note_untrusted_content_present(&self) { + self.inner + .lock() + .expect("assistant counters mutex poisoned") + .untrusted_content_present += 1; + } + + /// G-B3: `MAX_TURNS` was reached without a final answer. Three or more + /// times in one session (i.e. on this counters instance — a fresh + /// process starts a fresh count) raises an owner notice; this is the + /// practical brake on EV-13's read-only injection loop, which no + /// write-path guardrail ever sees. + pub(crate) fn note_max_turns_reached(&self) { + let count = { + let mut inner = self + .inner + .lock() + .expect("assistant counters mutex poisoned"); + inner.max_turns_reached += 1; + inner.max_turns_reached + }; + if count >= MAX_TURNS_REACHED_THRESHOLD { + self.owner_notice( + OwnerNoticeKind::Ux, + "The assistant has hit its per-turn step limit several times this session — \ + a request may be stuck in a loop. Consider rephrasing or narrowing what \ + you're asking for.", + ); + } + } + + /// G-B2/G-B3: a cloud backend answered this turn even though the local + /// (Ollama) leg was reachable and tool-capable — `reason` names why + /// (e.g. Ollama's model wasn't tool-capable). Always owner-visible; + /// this is a privacy signal, not an error. + pub(crate) fn note_cloud_escalation_while_local_up(&self, reason: &str) { + self.inner + .lock() + .expect("assistant counters mutex poisoned") + .cloud_escalation_while_local_up += 1; + self.owner_notice( + OwnerNoticeKind::Info, + format!("A cloud AI backend answered this turn even though the local backend is up: {reason}"), + ); + } + + /// G-B1: an outbound cloud request was blocked before it left this + /// node because it appeared to contain secret-shaped material. + /// Structurally should be unreachable (G-S5/S-11); if it ever fires, + /// a tool is returning something it must not — always a persistent, + /// security-flavoured notice. + pub(crate) fn note_blocked_egress(&self) { + self.inner + .lock() + .expect("assistant counters mutex poisoned") + .blocked_egress += 1; + self.owner_notice( + OwnerNoticeKind::Security, + "A request to a cloud AI backend was blocked before it left this node because it \ + appeared to contain secret material. This should not normally happen — if you \ + see this repeatedly, please report it.", + ); + } +} + +/// The process-wide counters instance, shared by every production call +/// site that has no `ToolExecCtx`/`AssistantCounters` handle threaded to +/// it already (e.g. `backends/claude.rs`, which implements the +/// backend-agnostic `Backend` trait and has no assistant-specific context +/// parameter). Mirrors `confirm::global()`'s pattern exactly. +pub fn global_counters() -> Arc { + static COUNTERS: OnceLock> = OnceLock::new(); + COUNTERS + .get_or_init(|| Arc::new(AssistantCounters::default())) + .clone() +} + /// D-02's promoted primary noun: a caller identity carrying the permission /// scope its tool calls resolve authority through. "A mesh peer" and "the /// local operator in AIUI" are two variants of it; Pine voice will be a