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:
archipelago
2026-08-05 22:22:15 -04:00
co-authored by Claude Fable 5
parent 265ba5ab19
commit fde7b1572d
4 changed files with 774 additions and 2 deletions
+239 -2
View File
@@ -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