feat(13-12): G-B1/G-B2 cloud-egress secret scan and turn-minimality screen
assistant/egress.rs: screen_outbound(body, ctx) -> EgressVerdict runs on every request body about to leave this node for a cloud backend. G-B1 scan_secret_shapes checks for macaroon-shaped hex runs, BIP39-length word runs, ecash/Nostr-key-shaped strings, and the literal contents of files under data_dir/secrets — a hit fails closed (BlockFallBackLocal), logging only the match's kind, never the value. G-B2 assert_turn_minimal checks the outbound body against a mechanical allowlist of 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 or content wrapped for a different turn is truncated out rather than eyeballed. An unparsable/ambiguous body also fails closed. MAX_OUTBOUND_CONTEXT_CHARS caps body size independent of minimality. Wired into backends/claude.rs's send() before the outbound HTTP request (on a block, send() errors before anything is sent — Rule 3, outside this task's originally-declared file list but structurally required to give screen_outbound a real caller); never wired into ollama.rs — nothing leaves the node on that leg, so paying the scan cost would be pointless. mod.rs: AssistantCounters/OwnerNotice — grant refusals, validation failures, turns-per-request, untrusted-content-present, cloud-escalation-while-local-up, blocked-egress and MAX_TURNS-reached counters, each raising an owner_notice() at its own AI-SPEC §7b threshold. Local and owner-facing only: no exporter, no /metrics, no OTLP anywhere in assistant/ or rate_limit.rs. backends/mod.rs's select_backend raises a cloud-escalation-while-local-up notice when Ollama is reachable but its configured model isn't tool-capable (Rule 3, same file-scope reasoning). 9/9 assistant::egress:: tests pass in this task's own isolated state (Task 1's 56 plus these 9 — ToolExecCtx's counters field and its loop_.rs call sites are Task 3's own commit, since nothing in this task's behavior needs them yet). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
265ba5ab19
commit
fde7b1572d
@@ -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::<Vec<_>>(),
|
||||
&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::<Value>(&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()?;
|
||||
|
||||
@@ -137,6 +137,12 @@ pub async fn select_backend(data_dir: &Path) -> (Box<dyn Backend>, 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())),
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<String> = 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<String> {
|
||||
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::<Value>(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<Value> = 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<u64>,
|
||||
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<Instant>,
|
||||
grant_refusals_without_untrusted: VecDeque<Instant>,
|
||||
notices: Vec<OwnerNotice>,
|
||||
}
|
||||
|
||||
/// 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<AssistantCountersInner>,
|
||||
}
|
||||
|
||||
/// 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<String>) {
|
||||
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<OwnerNotice> {
|
||||
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<AssistantCounters> {
|
||||
static COUNTERS: OnceLock<Arc<AssistantCounters>> = 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
|
||||
|
||||
Reference in New Issue
Block a user