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
@@ -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