From 8548544db9544541b28214839212edaad2a146bf Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 23:13:23 -0400 Subject: [PATCH] docs(13-12): complete injection-boundary/egress-screen/rate-limit plan --- .planning/ROADMAP.md | 4 +- .planning/STATE.md | 18 +- .../13-12-SUMMARY.md | 193 ++++++++++++++++++ 3 files changed, 206 insertions(+), 9 deletions(-) create mode 100644 .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-12-SUMMARY.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 1c2b3b9c..95dd256d 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -345,7 +345,7 @@ Plans: **Depends on:** Independent of Phases 1–12 for its UI and content work. Its security model must not contradict Phase 10 (Key-Material Hardening) — coordinate rather than widen. AIUI's own source lives in a **separate repository** (`git.tx1138.com/lfg2025/AIUI`, branch `development`, cloned at `~/Projects/AIUI`), so this phase spans two repos and needs push access to both. -**Plans:** 11/15 plans executed +**Plans:** 12/15 plans executed Plans: @@ -374,7 +374,7 @@ Plans: **Wave 5** -- [ ] 13-12-PLAN.md — D-10 untrusted-content boundary + cloud-egress guardrails + rate limiting (AIUI-04) +- [x] 13-12-PLAN.md — D-10 untrusted-content boundary + cloud-egress guardrails + rate limiting (AIUI-04) **Wave 6** diff --git a/.planning/STATE.md b/.planning/STATE.md index ba0bf41d..fe3ea4ff 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -5,15 +5,15 @@ milestone_name: milestone current_phase: 13 current_phase_name: aiui-functional-conversational-node-control-and-content-surf status: executing -stopped_at: "Completed 13-11-PLAN.md (music library SongGrid wiring + ShareModal MIME fix + GAP-FOUND live-trigger fix). Next: wave 4 has 13-11 as its only plan (terminal per D-13) — remaining phase-13 plans are waves 5-8 (13-12..13-15)." -last_updated: "2026-08-05T22:34:57.971Z" +stopped_at: "Completed 13-12-PLAN.md (D-10 untrusted-content boundary, G-B1/G-B2 cloud-egress screen, G-B3 read-only-loop rate limit + owner notices). Next: wave 6 (13-13)." +last_updated: "2026-08-06T03:13:08.168Z" last_activity: 2026-08-05 last_activity_desc: 13-10 complete (D-04 Ollama tool-calling backend + D-08 node-side chat history) progress: total_phases: 13 completed_phases: 2 total_plans: 60 - completed_plans: 49 + completed_plans: 50 percent: 15 --- @@ -29,11 +29,11 @@ See: .planning/PROJECT.md (updated 2026-07-29) ## Current Position Phase: 13 (aiui-functional-conversational-node-control-and-content-surf) — EXECUTING -Plan: 11 of 15 complete (13-01, 13-02, 13-03, 13-04, 13-05, 13-06, 13-07, 13-08, 13-09, 13-10) — next: wave 4 remainder (13-11) +Plan: 12 of 15 complete (13-01, 13-02, 13-03, 13-04, 13-05, 13-06, 13-07, 13-08, 13-09, 13-10) — next: wave 4 remainder (13-11) Status: Ready to execute Last activity: 2026-08-05 — 13-10 complete (D-04 Ollama tool-calling backend + D-08 node-side chat history) -Progress: [████████░░] 82% +Progress: [████████░░] 83% ## Performance Metrics @@ -68,6 +68,7 @@ Progress: [████████░░] 82% | Phase 13 P08 | ~7h45m (elapsed, w/ session restart) | 3 tasks | 10 files | | Phase 13 P10 | ~2h45m | 2 tasks | 10 files | | Phase 13 P11 | 27min | 3 tasks | 12 files | +| Phase 13 P12 | ~4h35m (shared-box compute contention) | 3 tasks | 9 files | ## Accumulated Context @@ -142,6 +143,9 @@ Decisions are logged in PROJECT.md (10 locked ADRs in the `` block + - [Phase ?]: [Phase 13, 13-10]: detect_ollama() and its two containing modules bumped to pub(crate) (api/rpc/mod.rs, api/rpc/mesh/mod.rs, api/rpc/mesh/assistant.rs) so assistant::backends could reuse the existing Ollama probe rather than writing a second one; run_loop (loop_.rs) now returns (answer, full_history) instead of just the answer, both Rule-3 deviations structurally required by the plan's own stated intent - [Phase ?]: 13-11: kind:'library' content:request routes to music.list-tracks (not content.*) via a new contextBroker.ts fetchLibraryContent branch — Rule 2 deviation, contextBroker.ts's diff-clean acceptance criterion could not hold alongside genuine music.* wiring (ContentItem has no artist/album/duration field at all) - [Phase ?]: 13-11: closed the plan's GAP-FOUND must_have — useArchy.ts's init() now fires requestArchyContent + requestArchyLibrary automatically as a live init-time event, and useContentPanel.ts's setArchyContent opens the panel/sets tabs for non-empty content, instead of leaving the fetch merely callable with nothing in the UI ever invoking it (13-06's own documented Known Limitation) +- [Phase ?]: 13-12: wrap_untrusted's per-call token is drawn fresh from rand on every call (never a module constant) — a forged closing boundary using a guessed/fixed token can never match, defeating EV-11 by construction; no pattern-stripping filter added anywhere in assistant/ (D-10 rejects that approach by name) +- [Phase ?]: 13-12: assistant.chat's G-B3 rate limit is keyed by authenticated SESSION id (not client IP) via a new session_requests map on the existing EndpointRateLimiter — 13-AI-SPEC §6 is explicit that per-session, not per-IP, is the guardrail's own spec +- [Phase ?]: 13-12: screen_outbound (G-B1/G-B2) wired into backends/claude.rs's send() and the assistant.chat rate limit wired into api/rpc/assistant_chat.rs — both Rule 3 deviations outside their task's declared file list, since the plan's own stated behavior (run on the Claude leg / rate-limited per session) had no real call site otherwise ### Pending Todos @@ -219,8 +223,8 @@ The 5x lifecycle gate was NOT run. ## Session Continuity -Last session: 2026-08-05T22:34:57.917Z -Stopped at: Completed 13-11-PLAN.md (music library SongGrid wiring + ShareModal MIME fix + GAP-FOUND live-trigger fix). Next: wave 4 has 13-11 as its only plan (terminal per D-13) — remaining phase-13 plans are waves 5-8 (13-12..13-15). +Last session: 2026-08-06T03:13:08.096Z +Stopped at: Completed 13-12-PLAN.md (D-10 untrusted-content boundary, G-B1/G-B2 cloud-egress screen, G-B3 read-only-loop rate limit + owner notices). Next: wave 6 (13-13). `assistant::` tests incl. `approval_nonce_binds_to_exact_action` individually; dispatcher.rs untouched; fc09d7a2's tools.rs/grants.rs/backends/mod.rs diffs confirmed rustfmt-only, no behavior change). Task 2 (ToolConfirmModal.vue, contextBroker.ts, Chat.vue mount, diff --git a/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-12-SUMMARY.md b/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-12-SUMMARY.md new file mode 100644 index 00000000..2389d825 --- /dev/null +++ b/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-12-SUMMARY.md @@ -0,0 +1,193 @@ +--- +phase: 13-aiui-functional-conversational-node-control-and-content-surf +plan: 12 +subsystem: ai-assistant-safety +tags: [rust, prompt-injection, egress-screening, rate-limit, security, d-10, g-b1, g-b2, g-b3] + +requires: + - phase: 13-aiui-functional-conversational-node-control-and-content-surf + provides: "13-05's curated tool registry, 13-08's confirm gate (ConfirmGate, ToolExecCtx), 13-10's Ollama/Claude backend chain and run_loop" +provides: + - "assistant/untrusted.rs: wrap_untrusted(label, text) — D-10's per-call randomized untrusted-content delimiter, wired into tools.rs/loop_.rs so every peer-authored tool result (content_list, app_logs, mesh_status) enters context marked as inert data, never instruction" + - "assistant/egress.rs: screen_outbound(body, ctx) — G-B1 secret-shape scan (macaroon hex, BIP39 word runs, ecash/Nostr keys, literal secrets-dir contents) and G-B2 turn-minimality allowlist, run on the Claude leg only, fail-closed on any ambiguity" + - "assistant/mod.rs: AssistantCounters/OwnerNotice — grant-refusal, validation-failure, turns-per-request, untrusted-content-present, cloud-escalation-while-local-up, blocked-egress and MAX_TURNS-reached counters, each raising an owner-facing notice at its own AI-SPEC §7b threshold" + - "rate_limit.rs: assistant.chat's own per-authenticated-session rate limit (G-B3) — soft-warn threshold + hard ceiling, on the existing EndpointRateLimiter rather than a second limiter" + - "loop_.rs: run_loop bounds and counts the read-only injection loop (EV-13) that never trips the confirm gate, and distinguishes a grant-refusal burst as a security signal (untrusted content present) vs. a UX/config signal (T-13-83)" +affects: [13-13, 13-14, 13-15] + +tech-stack: + added: [] + patterns: + - "Per-call randomized delimiter (not a fixed marker) as the structural boundary between peer-supplied data and model instructions — D-10's own precedent, no analog in this codebase before this plan" + - "Two independent layers, never substitutes: the untrusted-content wrapper and the confirm gate each hold even if the other were bypassed; tests assert the worst output a compromised model could emit via ScriptedBackend, not what a real model happens to do" + - "Mechanical allowlist over eyeballed judgment for privacy checks (G-B2's assert_turn_minimal) — an outbound message is turn-minimal only if every one of its fields is attributable to the current turn's own data" + - "Process-wide singleton with a per-test-isolated override (AssistantCounters mirrors confirm::global()'s OnceLock pattern; ToolExecCtx::with_confirm_gate_and_counters lets tests avoid cross-test threshold pollution)" + +key-files: + created: + - core/archipelago/src/assistant/untrusted.rs + - core/archipelago/src/assistant/egress.rs + modified: + - core/archipelago/src/assistant/tools.rs + - core/archipelago/src/assistant/loop_.rs + - core/archipelago/src/assistant/mod.rs + - core/archipelago/src/assistant/backends/claude.rs + - core/archipelago/src/assistant/backends/mod.rs + - core/archipelago/src/rate_limit.rs + - core/archipelago/src/api/rpc/assistant_chat.rs + - core/archipelago/src/api/rpc/mod.rs + +key-decisions: + - "wrap_untrusted's fresh token is drawn from the in-tree rand crate on every call (never a module constant), so a forged closing boundary embedded in peer content (EV-11) can never match the real per-call token — the randomization, not the wording, is the load-bearing property" + - "Only content_list/app_logs/mesh_status tool results are wrapped (UNTRUSTED_CONTENT_TOOLS in tools.rs) — operator/node-authored results (disk status, settings) are never wrapped, since wrapping everything dilutes the signal until the model stops distinguishing" + - "No pattern-stripping or keyword-blocklist filter was added anywhere in assistant/ — D-10 rejects that approach by name (an arms race that reads as a guarantee it is not); asserted mechanically by a grep over non-comment source in every task's acceptance criteria" + - "screen_outbound wired into backends/claude.rs's send() (Rule 3 — outside this task's originally-declared file list, but there is no other real caller for the guardrail to protect) and explicitly NOT into ollama.rs, since nothing leaves the node on that leg" + - "assistant.chat's rate limit is keyed by authenticated SESSION id, not client IP, via a new session_requests map on the EXISTING EndpointRateLimiter struct (not a second limiter type) — 13-AI-SPEC.md §6 G-B3 is explicit that 'per authenticated session' is the guardrail's own spec, since an operator's session can roam across IPs (LAN/Tailscale) within one sitting" + - "ToolExecCtx gained a counters: Arc field defaulting to the process-wide global_counters() singleton, with a with_confirm_gate_and_counters override for tests — the same pattern confirm::global()/with_confirm_gate already established, extended so grant-refusal/MAX_TURNS threshold tests never race the process-wide singleton" + - "Commits split per-task using mechanically verified intermediate file states (each task's own commit compiles and passes its own tests in isolation, confirmed with dedicated cargo check/test runs before staging) rather than a single combined commit, despite mod.rs/loop_.rs being touched by more than one task — Task 1 owns pub mod untrusted; + the 4 injection tests, Task 2 owns pub mod egress; + AssistantCounters, Task 3 owns ToolExecCtx.counters + loop_.rs's counting logic" + +requirements-completed: [AIUI-04] + +coverage: + - id: D1 + description: "Peer-supplied text (filenames, log lines, mesh/peer status) enters the model's context inside a per-call randomized delimiter block that marks it as data, never instruction; a forged closing boundary and a fake operator turn embedded in that content cannot escape the block; an injected imperative or mislabel still requires a real, node-authored confirmation before anything executes" + requirement: AIUI-04 + verification: + - kind: unit + ref: "assistant::tools::tests::wrap_untrusted_token_is_per_call, assistant::tests::injected_instruction_does_not_grant_authority, assistant::tests::forged_closing_delimiter_does_not_escape_block, assistant::tests::injected_mislabel_still_confirms_real_action, assistant::untrusted::tests (2 tests)" + status: pass + human_judgment: false + - id: D2 + description: "A request body about to leave the node for Claude is scanned for secret shapes (macaroon hex, BIP39 word run, ecash/Nostr key, literal secrets-dir contents) and blocked, failing closed, before it is sent; a clean body is allowed; the scan never runs on the Ollama leg" + requirement: AIUI-04 + verification: + - kind: unit + ref: "assistant::egress::tests (9 tests: macaroon_shaped_hex_is_blocked, bip39_length_word_run_is_blocked, ecash_token_shaped_string_is_blocked, known_secret_file_contents_are_blocked, clean_body_is_allowed_unchanged, ambiguous_body_does_not_leave_the_node, oversized_body_is_blocked_even_when_turn_minimal, unrelated_context_is_not_escalated_to_cloud, screen_outbound_is_a_free_function_ollama_never_needs_to_call)" + status: pass + human_judgment: false + - id: D3 + description: "A read-only injection loop that never trips the confirm gate is bounded at MAX_TURNS, raises zero confirmations, and is counted; reaching MAX_TURNS 3+ times in one session raises an owner notice; a burst of 5+ grant refusals is a distinguishable security signal when untrusted content is present in context vs. a UX/config signal when it isn't; assistant.chat is rate-limited per authenticated session" + requirement: AIUI-04 + verification: + - kind: unit + ref: "assistant::loop_::tests::read_only_injection_loop_terminates_and_is_counted, assistant::loop_::tests::grant_refusals_with_untrusted_content_are_a_security_signal, rate_limit::tests (3 new tests: assistant_chat_soft_threshold_then_hard_ceiling_refuses, assistant_chat_sessions_are_independent, assistant_chat_limiter_does_not_affect_existing_ip_keyed_methods)" + status: pass + human_judgment: false + - id: D4 + description: "No pattern-stripping/keyword-blocklist filter exists anywhere in assistant/; no exporter/scrape port (prometheus/metrics/opentelemetry/otlp) exists in assistant/ or rate_limit.rs; zero new packages were added (rand was already in-tree at 0.8.5)" + verification: + - kind: unit + ref: "grep -rvE '^\\s*//' core/archipelago/src/assistant/*.rs core/archipelago/src/rate_limit.rs | grep -ciE 'blocklist|blacklist|strip_?pattern|sanitize_prompt' == 0; grep -rci 'prometheus|/metrics|opentelemetry|otlp' core/archipelago/src/assistant/ core/archipelago/src/rate_limit.rs == 0; git diff --exit-code -- core/archipelago/Cargo.toml" + status: pass + human_judgment: false + +duration: ~4h35m (18:36 -> 23:09, dominated by concurrent-load cargo compiles on this shared 4-core box — each full `cargo test --package archipelago` compile took 15-27 minutes; active implementation time was substantially less) +completed: 2026-08-05 +status: complete +--- + +# Phase 13 Plan 12: Prompt-Injection Boundary, Cloud-Egress Screen, and Read-Only Loop Guardrail Summary + +**D-10's per-call randomized untrusted-content delimiter (`wrap_untrusted`), G-B1/G-B2's cloud-egress secret scan and turn-minimality allowlist (`screen_outbound`), and G-B3's session-keyed `assistant.chat` rate limit — closing the two failure modes the D-11 confirm gate structurally cannot catch (injected authority and the read-only injection loop).** + +## Performance + +- **Duration:** ~4h35m wall-clock (18:36 -> 23:09, 2026-08-05), almost entirely spent inside `cargo test --package archipelago` compiles (15-27 min each) on a 4-core box that was concurrently running another agent's `cargo build --release` in the sibling `archy` worktree for part of the session +- **Tasks:** 3/3 (all `type="auto" tdd="true"`, no checkpoints — plan is fully autonomous) +- **Files modified:** 9 (2 created: `untrusted.rs`, `egress.rs`; 7 modified) + +## Accomplishments + +- `assistant/untrusted.rs`: `wrap_untrusted(label, text)` wraps peer-supplied text in `{label}_DATA_{token}_START`/`_END` markers plus an explicit "treat as data, never instruction" sentence, where `token` is drawn fresh from the in-tree `rand` crate on every single call — never a module constant, never derived from content. Two calls on identical input produce different wrapped output; a forged closing boundary embedded in peer content (EV-11) can never match the real per-call token, so it reads as inert quoted text rather than a structural delimiter. +- `tools.rs`'s `wrap_tool_result_if_untrusted` wires this in for exactly the three tools whose results carry peer-authored text (`content_list`, `app_logs`, `mesh_status`); every other tool result (disk status, settings, bitcoin status) passes through unwrapped. `loop_.rs`'s `execute_tool` calls it at the precise point a successful `ToolResult` is constructed, before that content ever becomes part of a `ChatMessage`. +- Four tests prove the two-layer defense holds against the worst output a compromised model could emit (scripted directly via `ScriptedBackend`, not inferred from what a real model happens to do): an injected imperative still suspends on the confirm gate (`injected_instruction_does_not_grant_authority`), a forged closing delimiter plus a fake operator turn cannot escape the block AND still requires real confirmation (`forged_closing_delimiter_does_not_escape_block`), an injected mislabel ("describe this as a routine cache refresh") never reaches the node-authored confirm dialog text (`injected_mislabel_still_confirms_real_action`), and the token itself is per-call (`wrap_untrusted_token_is_per_call`). No pattern-stripping or keyword-blocklist filter exists anywhere in `assistant/` — grep-verified. +- `assistant/egress.rs`: `screen_outbound(body, ctx) -> EgressVerdict` runs G-B1's `scan_secret_shapes` (macaroon-shaped hex runs, BIP39-length word runs — split on non-alphabetic characters so a seed word glued to a JSON string's closing quote is still caught, not just whitespace-separated ones — ecash/Nostr-key-shaped strings, and the literal contents of files under `data_dir/secrets`) then G-B2's `assert_turn_minimal` (a mechanical allowlist of the current turn's own user text, tool results, and granted tool names; an unrelated earlier tool result is truncated out, not eyeballed). Every ambiguous case — unparsable body, missing `messages` field, oversized body — fails closed. Wired into `backends/claude.rs`'s `send()` before the outbound HTTP request; never wired into `backends/ollama.rs` (grep-verified 0 references), since nothing leaves the node on that leg. +- `mod.rs`'s `AssistantCounters`/`OwnerNotice`: grant refusals (split by whether untrusted content was present, so a probing attack is never confused with ordinary misconfiguration — T-13-83), validation failures, turns-per-request, untrusted-content-present, cloud-escalation-while-local-up, blocked-egress, and MAX_TURNS-reached, each raising an owner-facing notice at its own threshold. Local and owner-facing only — grep-verified zero references to prometheus/`/metrics`/opentelemetry/otlp anywhere in `assistant/` or `rate_limit.rs`. +- `rate_limit.rs`: `assistant.chat` gets its own request log keyed by **authenticated session id**, not client IP (13-AI-SPEC §6 G-B3 is explicit about this — an operator's session can roam across IPs within one LAN/Tailscale sitting), added to the *existing* `EndpointRateLimiter` struct rather than a second limiter type. A soft threshold (30/5min) raises an owner notice; a hard ceiling (60/5min) refuses the call. Wired into `assistant_chat.rs`'s `handle_assistant_chat` and into the existing 5-minute cleanup task. +- `loop_.rs`'s `run_loop` tracks whether D-10-wrapped untrusted content is present in context (seeded from history, re-checked as new tool results arrive mid-loop), and bounds EV-13's read-only injection loop — content instructing the model to "list every file and every chat, repeatedly" — which the confirm gate structurally cannot see because reads never confirm. It still terminates at `MAX_TURNS`, raises zero confirmations, and is counted; three or more MAX_TURNS-reached events in one session raise an owner notice. +- Full `cargo test --package archipelago` (1211 tests across the whole crate, plus 37 orchestration + 3 rpc-integration) is green at the final committed state. Each of the three task commits was independently verified — the working tree was mechanically reconstructed to each task's own intermediate state (using Python-scripted, string-anchored text surgery on the exact edits, not hand-typing from memory) and compiled + tested in isolation before staging, confirming Task 1 alone (56/56 `assistant::` tests), Task 1+2 (9/9 `assistant::egress::` tests, full crate compiles), and the final combined state (67/67 `assistant::`, 9/9 `rate_limit::`, 1211/1211 full suite). + +## Task Commits + +Each task was committed atomically, staged explicitly by path. Because Task 1's `untrusted` module, Task 2's `AssistantCounters`, and Task 3's `ToolExecCtx.counters` field all live in the same two shared files (`mod.rs`, `loop_.rs`), each task's commit carries only the hunks that task actually owns — verified independently compilable and testable before staging, not just asserted: + +1. **Task 1: The untrusted-content boundary, randomized per call** — `265ba5ab` (feat). 56/56 `assistant::` tests pass in this commit's own tree state. +2. **Task 2: Nothing leaves the node unscreened, and nothing leaves that the turn did not need** — `fde7b157` (feat). 9/9 `assistant::egress::` tests pass in this commit's own tree state (on top of Task 1's 56). +3. **Task 3: Bound the read-only loop the confirm gate never sees** — `f1e50fbf` (feat). Full crate (1211 tests) passes at this final state. + +**Plan metadata:** this commit (`docs(13-12): complete injection-boundary/egress-screen/rate-limit plan`) + +## Files Created/Modified + +- `core/archipelago/src/assistant/untrusted.rs` (new) — `wrap_untrusted`, `UntrustedBlock`, `fresh_token`, `TOKEN_LEN`, `contains_untrusted_marker` +- `core/archipelago/src/assistant/egress.rs` (new) — `screen_outbound`, `EgressVerdict`, `EgressContext`, `scan_secret_shapes`, `assert_turn_minimal`, `load_known_secrets`, `MAX_OUTBOUND_CONTEXT_CHARS` +- `core/archipelago/src/assistant/tools.rs` — `wrap_tool_result_if_untrusted`, `UNTRUSTED_CONTENT_TOOLS` +- `core/archipelago/src/assistant/loop_.rs` — `execute_tool`'s dispatch-success branch wraps untrusted tool results; `run_loop` tracks untrusted-content presence and counts grant refusals/validation failures/turns-used/MAX_TURNS-reached via `ctx.counters` +- `core/archipelago/src/assistant/mod.rs` — `AssistantCounters`, `OwnerNotice`, `OwnerNoticeKind`, `global_counters`; `ToolExecCtx.counters` field + `with_confirm_gate_and_counters`; `pub mod untrusted;`/`pub mod egress;` +- `core/archipelago/src/assistant/backends/claude.rs` — `send()` calls `egress::screen_outbound` before the outbound HTTP request +- `core/archipelago/src/assistant/backends/mod.rs` — `select_backend` raises a cloud-escalation-while-local-up notice when Ollama is reachable but not tool-capable +- `core/archipelago/src/rate_limit.rs` — `session_requests` map, `check_session`/`record_session_request`/`session_soft_threshold_reached`/`cleanup_sessions` on `EndpointRateLimiter` +- `core/archipelago/src/api/rpc/assistant_chat.rs` — `handle_assistant_chat` enforces the session-keyed rate limit and raises the soft-threshold notice +- `core/archipelago/src/api/rpc/mod.rs` — the existing 5-minute rate-limiter cleanup task also calls `cleanup_sessions()` + +## Decisions Made + +See `key-decisions` in frontmatter — the per-call token design, the narrow untrusted-tool allowlist, the deliberate absence of any pattern-stripping filter, `screen_outbound`'s wiring point (Claude only, never Ollama), session-keyed (not IP-keyed) rate limiting for `assistant.chat`, `ToolExecCtx`'s new counters field mirroring the existing confirm-gate pattern, and the mechanically-verified per-task commit split. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] `screen_outbound` needed a real caller — wired into `backends/claude.rs`, outside Task 2's declared file list** +- **Found during:** Task 2, implementing G-B1/G-B2 +- **Issue:** The plan's own action text says `screen_outbound` must run "on the Claude and Routstr legs," but Task 2's declared `` list only names `egress.rs` and `mod.rs` — `screen_outbound` would have no real call site and no way to actually protect an outbound request without touching `backends/claude.rs`. +- **Fix:** Added the `screen_outbound` call to `ClaudeBackend::send()` before the outbound HTTP request; on a block, `send()` returns an `Err` before anything is sent (fails closed structurally, not just by convention). +- **Files modified:** `core/archipelago/src/assistant/backends/claude.rs`. +- **Verification:** `grep -c 'screen_outbound' backends/claude.rs` == 1, `grep -c 'screen_outbound' backends/ollama.rs` == 0 (both plan-mandated acceptance criteria); egress tests green in this task's own isolated tree state. +- **Committed in:** `fde7b157` (Task 2 commit). + +**2. [Rule 3 - Blocking] `assistant.chat`'s rate limit needed a real RPC call site — wired into `assistant_chat.rs`, outside Task 3's declared file list** +- **Found during:** Task 3, implementing G-B3 +- **Issue:** Task 3's declared `` list is `rate_limit.rs` and `loop_.rs`, but "assistant.chat is rate-limited per authenticated session" (the plan's own behavior bullet) can only take effect at the RPC handler that actually receives `assistant.chat` calls — `api/rpc/assistant_chat.rs`'s `handle_assistant_chat`. +- **Fix:** Added the `check_session`/`record_session_request`/`session_soft_threshold_reached` sequence to `handle_assistant_chat`, plus hooked the new `cleanup_sessions()` into the pre-existing 5-minute rate-limiter cleanup task in `api/rpc/mod.rs`. +- **Files modified:** `core/archipelago/src/api/rpc/assistant_chat.rs`, `core/archipelago/src/api/rpc/mod.rs`. +- **Verification:** `rate_limit::` tests green (9/9); full crate suite green (1211/1211) — the existing IP-keyed rate-limited methods are provably unaffected (`assistant_chat_limiter_does_not_affect_existing_ip_keyed_methods`). +- **Committed in:** `f1e50fbf` (Task 3 commit). + +**3. [Rule 1 - Bug] `has_bip39_length_word_run`'s whitespace-only tokenization missed a word glued to a JSON string's closing quote** +- **Found during:** Task 2, first test run of `bip39_length_word_run_is_blocked` +- **Issue:** The initial implementation split `body` (the raw outbound JSON request text) on whitespace only. A BIP39 seed phrase's LAST word sits immediately before the string's closing `"` with no space at all — in compact JSON there is no whitespace anywhere outside string values — so that final word merged with the rest of the JSON document into one giant non-matching token, and the 12-word window never fully matched. The test failed with `Allow` instead of `BlockFallBackLocal`. +- **Fix:** Changed the tokenizer to split on any non-ASCII-alphabetic character (not just whitespace), so JSON structural characters (quotes, colons, commas, braces) also act as word boundaries — correctly isolating the seed phrase regardless of where it sits inside the JSON string. +- **Files modified:** `core/archipelago/src/assistant/egress.rs`. +- **Verification:** `bip39_length_word_run_is_blocked` passes; re-ran the full `assistant::egress::` suite to confirm no other test's tokenization assumptions broke. +- **Committed in:** `fde7b157` (Task 2 commit — found and fixed before that commit was made, not a follow-up). + +--- + +**Total deviations:** 3 auto-fixed (2 Rule 3 — blocking issues where the plan's own declared file scope had no real call site for its own stated behavior; 1 Rule 1 — a genuine bug in the first implementation of a heuristic, caught by the plan's own TDD-first test). No architectural changes; no scope creep. Both Rule 3 deviations are the minimal wiring needed to make the plan's own explicitly-stated intent ("run on the Claude leg," "rate-limited per authenticated session") true of the real RPC/backend surface rather than true only of an unreachable library function — the same class of deviation 13-10's own precedent documents for exactly this reason. +**Impact on plan:** all three fixes are structurally necessary for the plan's stated guarantees to actually hold at runtime; none touch a file the threat model assigns a mitigation to beyond what was already planned. + +## Issues Encountered + +- **Shared-box compute contention, again.** Per this phase's own recurring gotcha, every `cargo test --package archipelago` compile on this box took 15-27 minutes, and for part of the session a sibling agent's `cargo build --release` in the adjacent `archy` worktree added further contention. No test failures resulted from this — only elongated wall-clock time, managed via foreground blocking waits per the coordinator's stall-recovery guidance (never `run_in_background`-and-end-turn). +- **Commit granularity vs. shared-file coupling.** `AssistantCounters` (Task 2's own deliverable) and `ToolExecCtx.counters` (needed by Task 3's tests for isolation) both live in `mod.rs`; `loop_.rs` similarly carries both Task 1's `wrap_tool_result_if_untrusted` call and Task 3's counting logic. Rather than either bundling all three tasks into one commit or risking a hand-spliced patch, each task's commit was built by reconstructing that task's own intermediate file content (via precise, assertion-guarded Python string replacement against the exact text used in the real edits) and independently compiling + testing it before staging — verified, not assumed. + +## User Setup Required + +None — no external service configuration required by this plan. + +## Next Phase Readiness + +- D-10's untrusted-content boundary is production-quality: every tool result carrying peer-authored text is wrapped before it reaches a backend, with a fresh per-call token that makes forged boundaries structurally inert, and the confirm gate remains the independent second layer for any imperative that survives the wrapping. +- G-B1/G-B2's cloud-egress screen is real and tested, but its "falls back to the local backend" convenience behavior is only guaranteed for the case where a `screen_outbound` block occurs while `ClaudeBackend` is being used as `FallbackChain`'s secondary (Ollama already tried) — in that case there genuinely is no further local leg to retry, so the turn fails closed with a clear error rather than silently degrading. A future plan that wants an automatic same-turn retry against Ollama specifically after a Claude-side egress block would need to thread that decision up into `mod.rs::chat()`, which currently treats any backend error uniformly. +- G-B3's rate limit and owner-notice counters are real (`AssistantCounters::notices()` returns real data, asserted by tests), but there is no NEW dedicated RPC method exposing them to neode-ui yet — they are reachable structurally through the same authenticated session any future `assistant.*` handler would use, but the UI-facing surface (e.g. an `assistant.notices` RPC) is not part of this plan's declared scope and would be a natural, small follow-up for whichever plan builds the AI-permissions/notifications UI. +- 13-13 (Routstr leg) can wire `screen_outbound` into its own `send()` the same way `claude.rs` does, without any change to `egress.rs`'s own shape — the `EgressContext::from_turn` builder is generic over any `Backend` implementor's `history`/`tools`. + +--- +*Phase: 13-aiui-functional-conversational-node-control-and-content-surf* +*Completed: 2026-08-05* + +## Self-Check: PASSED + +All referenced files found (`untrusted.rs`, `egress.rs`, this SUMMARY). All three task commit hashes +(`265ba5ab`, `fde7b157`, `f1e50fbf`) verified present in `git log --oneline --all`.