feat(13-12): G-B3 rate limit + read-only injection loop bound and owner notices

rate_limit.rs: assistant.chat gets its own request log keyed by
AUTHENTICATED SESSION (not client IP, per 13-AI-SPEC.md §6 G-B3's own
spec — an operator's session can roam across IPs within one sitting), on
the SAME EndpointRateLimiter struct rather than a second limiter type.
check_session/record_session_request enforce a hard ceiling (60/5min);
session_soft_threshold_reached (30/5min) is checked separately so the
call site can raise an owner notice before the hard refusal ever fires.
Wired into assistant_chat.rs's handle_assistant_chat (Rule 3 — the plan's
own declared intent, "assistant.chat is rate-limited per authenticated
session," has no other call site to reach the real RPC surface) and into
the existing 5-minute cleanup task in api/rpc/mod.rs.

loop_.rs: run_loop now tracks whether D-10-wrapped untrusted content is
present in context (seeded and re-checked as new tool results arrive
mid-loop), counts grant refusals split by that flag via
AssistantCounters::note_grant_refusal (a burst WITH untrusted content
raises a Security notice — something in shared content may be trying to
trigger actions; the same burst WITHOUT it raises a Ux/config notice
instead, so probing is never confused with misconfiguration, T-13-83),
counts turns-per-request, and counts MAX_TURNS-reached (3+ in one session
raises an owner notice) right before the loop's own bail — this is EV-13's
read-only injection loop, the one case the confirm gate structurally
cannot see because reads never confirm.

mod.rs: ToolExecCtx gains a `counters: Arc<AssistantCounters>` field
(defaulting to the process-wide global_counters(), overridable per-test via
with_confirm_gate_and_counters) so loop_.rs's counting has somewhere to
write and tests can assert against an isolated instance without polluting
concurrently-running tests.

read_only_injection_loop_terminates_and_is_counted (EV-13) and
grant_refusals_with_untrusted_content_are_a_security_signal (T-13-83) both
pass. Full `cargo test --package archipelago` (1211 tests) green — the
existing rate-limited RPC methods are unaffected by the new session-keyed
limiter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-05 23:09:51 -04:00
co-authored by Claude Fable 5
parent fde7b1572d
commit f1e50fbfd8
5 changed files with 402 additions and 2 deletions
+23
View File
@@ -375,6 +375,13 @@ pub struct ToolExecCtx {
/// share one pending queue; tests inject a fresh gate per test via
/// [`ToolExecCtx::with_confirm_gate`] for isolation.
pub confirm: Arc<confirm::ConfirmGate>,
/// 13-12 Task 2/3: grant-refusal/validation-failure/turns-used/
/// untrusted-content/MAX_TURNS counters and owner notices. Defaults to
/// [`global_counters`] so production call sites share one instance;
/// tests inject an isolated one via
/// [`ToolExecCtx::with_confirm_gate_and_counters`] to avoid
/// cross-test threshold pollution.
pub counters: Arc<AssistantCounters>,
validation_failures: Mutex<HashMap<String, u32>>,
/// 13-08 on-device UAT (T-13-50): actions the human declined this turn,
/// keyed by the same canonical `(tool_name, validated_args)` identity
@@ -399,12 +406,28 @@ impl ToolExecCtx {
caller: CallerScope,
handler: Arc<RpcHandler>,
confirm: Arc<confirm::ConfirmGate>,
) -> Self {
Self::with_confirm_gate_and_counters(registry, caller, handler, confirm, global_counters())
}
/// Like [`ToolExecCtx::with_confirm_gate`], but also overrides the
/// counters instance — tests use this to get an isolated
/// `AssistantCounters` so threshold assertions (grant-refusal bursts,
/// MAX_TURNS-reached) can't be polluted by other tests running
/// concurrently against the process-wide singleton.
pub fn with_confirm_gate_and_counters(
registry: tools::ToolRegistry,
caller: CallerScope,
handler: Arc<RpcHandler>,
confirm: Arc<confirm::ConfirmGate>,
counters: Arc<AssistantCounters>,
) -> Self {
Self {
registry,
caller,
handler,
confirm,
counters,
validation_failures: Mutex::new(HashMap::new()),
declined_actions: Mutex::new(HashSet::new()),
}