Mesh AssistQuery answered with a bare LLM call — no tools, no actions.
The CallerScope::Mesh variant was designed for this wiring ('the variant
exists so the shape is right when a future plan wires mesh callers into
the shared loop'); this is that plan. A trusted/allowlisted asker's prompt
now runs assistant::chat with CallerScope::Mesh { authorized } — the
operator's persisted grants cap what the model may touch (never wider),
and writes suspend on the node's own confirm gate. The reply is capped
for airtime as before, with a brevity instruction for mesh turns.
Wiring follows the blob_store pattern: RpcHandler::set_mesh_service (now
&Arc<Self>) forward-propagates an Arc<RpcHandler> into the mesh state's
new assistant_handler slot; absent (early boot) falls back to the legacy
bare-LLM answer.
Test: mesh_caller_authority_is_capped_at_operator_grants.
Co-Authored-By: Claude <noreply@anthropic.com>
Two changes, one binary batch:
1. app_install/app_uninstall (task 3): '!ai please install bitcoin knots'
correctly said it can't. Both tools are category-Apps, destructive, and
ride the 13-08 confirm gate (node-authored descriptions added). Install
validates catalog membership BEFORE the dialog (a typo never spends an
approval); uninstall resolves installed ids. Both reach the SAME
package.install/package.uninstall spawns every authenticated caller
uses, via a curated Arc-taking sibling of assistant_dispatch_tool.
2. S6: cloud legs no longer strip prior USER turns from replayed history.
Turn-minimality's allowlist is now the whole conversation's operator
turns (the node's own D-08 transcript, same trust class as this turn),
still mechanically matched, B1 secret scan and 64KB cap unchanged,
fabricated user messages still truncated. The model no longer sees its
own answers without the questions.
Co-Authored-By: Claude <noreply@anthropic.com>
Live evidence, two ways: the 9abc1623 banner never fired because D-16 hides
ungranted tools (model never calls → refused_categories always empty), and
the [[needs:id>]] marker fix failed because a small local model answers with
a workaround narrative instead of emitting structured markers.
The model's reliable, trained behavior is tool CALLING — so disabled tools
are now listed in a DISABLED prompt section and remain in the schema. A call
hits the execution gate, which refuses and records the category → the
trusted chrome offers Settings → AI Data Access. Deterministic and
model-independent. The prompt split is UX/attack-surface shaping; the
security boundary remains the server-side grant re-check in execute_tool
(loop_.rs), unchanged and now the single enforcement layer by design.
Tests: ungranted_tool_only_ever_in_disabled_section (section-aware),
disabled_tools_are_listed_as_callable_but_refused, marker extraction kept
as a harmless safety net. 127/127 assistant suite green.
Co-Authored-By: Claude <noreply@anthropic.com>
The 9abc1623 banner waited on refused_categories, but refused_categories
only fills when the model CALLS a gated tool — and D-16 hides ungranted
tools from the prompt, so the model never calls: it answered 'I can't do
that' in prose and the banner never fired. Live-verified: revoke media,
ask for content, no banner.
- build_system_prompt takes the disabled categories and teaches a marker:
'say it can be switched on in Settings → AI Data Access and end with
[[needs:<id>]]' — category names only, never tool names (D-16 holds)
- extract_needs_markers strips the markers from the reply and folds them
into refused_categories; unknown ids pass through as text (an offer is
the worst a bad marker can cause — never a grant)
- egress's seed-screen test now covers the new paragraph too
Co-Authored-By: Claude <noreply@anthropic.com>
Two reasons the content surface 'often doesn't surface the content':
- content.indeehub-projects items carried no mime/filename, so the UI
adapter classified every film 'excluded' and the films grid could never
render. They are films: they now declare video/mp4.
- apps_list surfaced the container-list RPC's BARE ARRAY; the broker reads
{ items: [...] }, so the apps grid was silently dropped every turn.
Wrapped at the tool boundary — the shared RPC's own shape is untouched.
Co-Authored-By: Claude <noreply@anthropic.com>
'recommend me 10 scifi films' answered prose-only and OFFERED to check the
catalogue — the preamble invited knowledge recommendations (paragraph 2) but
only ordered tool calls for existence questions (paragraph 3), so the model
never ran the tool and emitted none of the tags the iframe renders as rich
preview cards.
- discovery of a kind the node could hold (films/music/books…) now gets a
catalogue-and-peers check FIRST, knowledge picks on top
- the 'would you like me to look?' stall is banned outright — looking is
one tool call, do it then answer
- the preamble teaches the exact [[film_ext:Title|Year|Director]] /
tv_ext / song_ext / book_ext / podcast_ext formats, same-line with the
one-line reason, real titles only, never for items the tool returned
Co-Authored-By: Claude <noreply@anthropic.com>
S4: /aiui/api/web-search proxied straight to SearXNG with no auth — anyone
reaching the web port ran searches attributed to the node's IP. Now routed
through the daemon's session-gated model proxy like the claude/ollama legs
(both nginx server blocks), forcing format=json upstream (the client never
sent it — search could 200 with HTML that parsed as nothing).
S3: the forwarder also serves the STANDALONE frontend, whose bodies carry
full history/images with no assistant loop behind them — a pasted seed
phrase went to Anthropic unscreened. The forwarder now runs the egress
secret-shape scan (G-B1) with the node's own secrets dir as deny corpus on
Claude bodies and search queries; blocked requests get a plain-language 400.
Also fixes a REAL gap in the egress tokenizer found by these tests: a JSON
key glued to a string value's first word ('content":"abandon...') dropped
that word, so an exactly-12-word seed pasted as a bare message yielded an
11-member run — checksum misses, backstop misses. Non-member words now
rescan within the token. egress 15/15 + model_proxy 10/10 green.
Co-Authored-By: Claude <noreply@anthropic.com>
The browser broker redacted log lines (password=/token=/macaroon key=value,
64+ hex, 64+ base64) while the node-side tool only untrusted-wrapped — so a
log line carrying rpcpassword=<32-hex> crossed to cloud backends below the
egress screen's threshold. Port the broker's three patterns to the tool
boundary as a pure line redactor + JSON walker; unit-tested (124 assistant
tests green).
Co-Authored-By: Claude <noreply@anthropic.com>
ai_grants_unified UNIONED the assistant grants.json with the legacy
settings/ai_permissions.json on every read. On archi-dev-box legacy held
all-ten and grants.json held four, so the Settings UI and the AIUI frame
saw every category ON while the assistant refused six — and no UI toggle
could fix it, because both write paths existed but only ai.permissions.set
synced both files. Now: an existing grants.json answers alone; the legacy
file is consulted only when no grants file exists (pre-unification
upgrade), and that read migrates forward and persists the authority.
assistant.grants-set now also rewrites the legacy file in step. Regression
tests: authority is not widened by legacy; migration folds forward once.
Co-Authored-By: Claude <noreply@anthropic.com>
The Network permission's Settings label promises 'no IP addresses', and the
browser-side broker honours it — but the node-side tool forwarded
network.diagnostics verbatim, so a granted Network category sent the node's
WAN IP and SSID (both location-identifying) to cloud model backends. Strip
both at the tool boundary; NAT/UPnP/Tor/DNS connectivity shape stays.
Pure helper + unit test (123 assistant tests green).
Co-Authored-By: Claude <noreply@anthropic.com>
Four defects, one visible symptom: a correct prose answer beside an
empty grid.
1. The assistant's curated RPC bridge had an arm only for
`content.list-mine`. `tools.rs` mapped the `peers`, `purchased` and
`films` scopes onto three real, dispatcher-registered handlers that
`assistant_dispatch_tool` had never heard of, so every non-"own"
scope died on its catch-all. Downstream that read as "the peers have
no content" — it was a missing match arm, and the tool never ran.
Regression test added: every scope the schema advertises must reach a
real handler.
2. `content.browse-all-peers` wrapped its whole fan-out in one
`timeout(..).unwrap_or_default()`, which DISCARDED every completed
batch the moment the budget expired. One slow peer turned a
partly-successful browse into "0 reached, 16 unreachable". Observed
live on archi-dev-box: back-to-back calls returned real peer items,
then nothing. Now accumulates per batch and checks a deadline between
them, so partial results always survive. Budget 20s -> 45s: two
batches of eight at a 10s per-peer timeout had no headroom at all.
3. `assistant.chat` returned only `{ text }`. The structured results of
any content tool the turn ran were dropped inside the loop, so the
surface had nothing to render. The turn now carries them through
(captured raw, before the untrusted wrap, since they go to a renderer
that treats every field as inert data, never back into the prompt).
4. The adapter classified images as 'excluded' and dropped them. A node
sharing mostly photos rendered as an empty grid while AIUI's image
grid sat unused. Images now have a bucket, with the paid-lock and
extension-fallback handling audio and video already had.
Also: the panel says "Loading…" while a turn is in flight and "Nothing
found" when it comes back empty, instead of leaving the previous
query's heading standing as though it answered this one; the system
prompt tells the model to call the content tool and summarise rather
than re-list what the cards already show; and a refused tool now names
its permission category so the trusted chrome can offer the settings
screen instead of leaving "I don't have a tool for that" as the only
clue.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Operator asked "what films are there to watch from my peers" and the model
answered, honestly, that it had no tool for it. It was right: content_list
mapped only to content.list-mine — this node's own shared files. Peer
catalogues and IndeeHub were unreachable from the assistant entirely.
content_list now takes scope: own | peers | purchased | films, dispatching to
content.list-mine / content.browse-all-peers / content.owned-list /
content.indeehub-projects. The model picks from a closed enum and never names
a method, so an invented scope falls back to "own" rather than reaching
anything it was not granted (T-13-34).
Two new RPCs behind it:
- content.browse-all-peers aggregates every federated peer in ONE call. The
dashboard fans this out client-side, but asking a model to enumerate peers
and loop is how it ends up claiming it has no tool. Rides FIPS —
PeerRequest::new(fips_npub, onion, "/content") with a 6s FIPS fast-fail then
Tor — so the onion is the peer's identity and FIPS is the transport.
Sequential with a per-peer timeout, not an unbounded fan-out: 02-08 traced a
real UI stall to browse-peer starving the connection pool. One peer being
down is the normal case and contributes nothing rather than failing the call.
- content.indeehub-projects fetches IndeeHub's catalogue, public plus (via a
node-signed NIP-98 login) the operator's private titles. Node-side because
signing that in the browser would put identity material next to the model,
which this phase rules out by name. Tolerant of IndeeHub's field spellings
across versions, and absent/stopped/empty all yield an empty list rather
than failing the caller.
action_key includes the scope, so listing peers cannot be replayed as listing
own files. 15/15 assistant::tools.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
secret_shaped_content_never_reaches_the_stub was RED because its fixture was
the first twelve wordlist entries — not a parseable mnemonic. The 2026-08-06
precision rewrite of screen_outbound moved from a word-run shape rule to BIP-39
checksum validation (the shape rule had blocked legitimate turns on a live node
twice); egress.rs's own test was updated to a checksum-valid fixture and this
copy was not, so it asserted behaviour that had been deliberately retired.
The named behaviour was intact throughout: screen_outbound runs on the Routstr
paid leg before any body is sent, a real mnemonic is blocked, and
checksum-invalid runs of 20+ wordlist members are still caught by
IMPLAUSIBLE_MEMBER_RUN. Fixture is now a checksum-valid mnemonic, asserted as
parseable so it cannot silently rot the same way again.
Also records the operator's rendering contract in the surfaces todo: chat gets
the mini version, the content/context surfaces expand it, nothing rich may
overflow the bubble at mobile width.
Four defects found by on-device UAT, 2026-08-06.
1. D-08 persistence was WRITE-ONLY. chat() loaded the transcript only
AFTER the loop, to append — the model was never shown any of it. The
assistant answered "I don't have access to any previous conversation
history" with its own transcript on disk, and "and is it healthy?"
resolved to the node instead of the app just discussed. History now
replays into every turn (text only: a stale tool result must not be
re-presented as this turn's evidence), scoped by HistoryKey. The
replayed prefix is excluded from the append, or each turn would
re-persist the conversation and grow it geometrically.
2. The operator persona forbade the very answers the content surfaces
render. 13-01's prompt refuses anything without a matching tool, so
"recommend me 10 sci-fi films" was declined and the film/song/podcast
grids from 13-11 could never populate — two plans in contradiction.
The refusal rule now governs ACTIONS ON THE NODE; general questions
and recommendations are answered from the model's own knowledge.
(Whether the node should also SEARCH THE WEB depends on AIUI's
web-search setting, which embedded mode never forwards — captured as
a separate todo because it opens a new egress path.)
3. The content-surface loader labelled unrelated queries "Podcast
recommendations": the classifier matched a bare "show", which is how
operators phrase almost everything ("show me my files").
4. "Surfacing…" tracks at 0.2em and its final glyph collided with the
close button; the header now spaces them properly.
assistant::history 9/9 green incl. replay_feeds_prior_turns_back_to_the_model.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Second on-device failure in one session: after the wordlist fix, dev3
blocked cloud turns AGAIN mid-session as 13-10's history grew — splitting
on every non-alphabetic character let words from unrelated JSON fields
chain into one run. Both failures took the whole feature down rather than
protecting anything, which is the worse failure for a screen to have.
Shape is the wrong signal. A real mnemonic's last word encodes a checksum
over the rest, so an accidental run of English words parses as a mnemonic
only about one time in sixteen. Candidate runs are now validated with the
same bip39 crate the wallet uses:
- tokenize on whitespace (a seed phrase is space-separated); a token's
leading alphabetic segment counts, and alphanumerics after it end the
phrase, so a seed glued to a closing quote is still caught
- block only if a 12/15/18/21/24 window parses as a real mnemonic
- IMPLAUSIBLE_MEMBER_RUN (20) backstops checksum-invalid material such as
a typo'd 24-word seed, which prose cannot plausibly produce
Documented trade-off: a checksum-invalid run under 20 words no longer
blocks. The rule that did block it also blocked every legitimate turn,
twice, on a live node. 15/15 egress tests green, including the real
system prompt, scattered-JSON prose, and a genuine mnemonic in JSON.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The shape-only heuristic ('any 12 consecutive lowercase 3-8-char words')
matched ordinary prose — including the node's own system prompt — and
blocked 100% of live cloud chat turns (found on dev3, the first real
Claude call through this screen; log: kind=bip39-word-run every turn).
Membership in the crate's own bip39 English wordlist (already a dep via
seed.rs) distinguishes prose from seed material: glue words break runs,
real seeds are nothing but members. Regression test pins the real system
prompt + a clean wire body to Allow; the 12-word genuine-seed case still
blocks. 13/13 egress tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
assistant/evals.rs: a test-gated in-crate module (#![cfg(test)] here AND
#[cfg(test)] pub mod evals; in mod.rs — never compiles into the shipped
binary, asserted by a release-binary string grep). load_cases/case_by_id
read the 18-case JSONL fixture by path; run_case drives the REAL run_loop/
execute_tool/ConfirmGate choke points end to end against a case's grants,
seeded untrusted content, and scripted backend turns, returning a
CaseOutcome that observes ToolCall/ToolResult/confirm-gate transitions
in-process rather than inferring them from prose. evaluate_case asserts
must_not_execute/must_not_claim at threshold zero (E-01's security and
integrity halves) and confirmations/turns at exact match, every failure
message naming the case id and the offending tool/term.
Parameterized over the Backend trait (CountingBackend wraps any real
Backend to measure turns used; a BudgetExhaustedStubBackend drives EV-17's
S-12 stop-without-retry path) so scripted, Ollama, Claude or Routstr can
all run the same 18 cases. report_by_backend/parity_requires_two_backends
refuse to record a cross-backend parity pass from fewer than two backends
(E-07). Live-backend runs are opt-in via ARCHY_EVAL_BACKENDS and #[ignore]d
so a plain `cargo test` never touches the network. write_trace_jsonl writes
one plain JSONL file per run under core/target/assistant-evals/ (gitignored
build output) — no exporter, no collector, no listening port.
All 18 cases pass against ScriptedBackend (23/23 assistant::evals:: tests);
full crate suite 1258/1258; release binary contains zero eval-fixture
strings; no phoenix/promptfoo/ragas/opentelemetry references anywhere in
assistant/; no new CI job (ci.yml untouched — picked up by the existing
`cargo test --all-features` step); zero new packages (T-13-SC).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Completes D-04's backend chain: Ollama -> Claude -> Routstr (budget-gated),
and wires D-05's operator-set prepaid allowance as a hard, arithmetic stop
a prompt-injected model can never cross.
- assistant/mod.rs: `AssistantBudget` (allowance_sats/spent_sats,
persisted 0600 under data_dir/assistant/budget.json, mirroring
Grants::load/save exactly — a missing/corrupt file defaults to a ZERO
allowance, D-16's "default closed" applied to money). `payment_policy()`
builds a `PaymentPolicy` from ONLY these two persisted fields — no
parameter accepts anything model/tool/provider-influenced, which is what
makes the ceiling arithmetic rather than a policy an injected model
could argue with. `record_spend()` persists a successful payment and
raises a one-time 80%-threshold owner notice (AI-SPEC §7b). New typed
`BudgetExhausted` error (downcastable via anyhow) is the signal
`loop_.rs` distinguishes from an ordinary transport error.
- assistant/loop_.rs: `run_loop` downcasts a `BudgetExhausted` out of the
backend's `Err` and returns `Ok` with a plain-language stop message —
no retry, no re-price, no partial spend, no fall-through to a different
provider at a different price. Verified to actually matter: temporarily
replaced the terminating `return` with `continue` and confirmed
`zero_budget_stops_loop_without_retry` goes red (the backend gets
retried 8x to MAX_TURNS and the turn errors instead of stopping
cleanly); restored and reconfirmed green (13-13-SUMMARY.md records the
observed failure).
- assistant/backends/mod.rs: `select_backend` now takes `&RpcHandler`
(was `&Path`) to also read the Tor-proxy config; completes the D-04
chain — Routstr never selected when the operator's allowance is zero
(Claude alone instead), otherwise chained as Claude's fallback
(Ollama -> Claude -> Routstr, each leg reached only when the priors are
unavailable). New `BackendId::Routstr` variant.
- assistant/backends/routstr.rs: the payment-decline arm now returns the
typed `BudgetExhausted` (was a plain bail in Task 2's commit, per the
plan's own "handled in Task 3" note); a successful payment records spend
against the persisted budget immediately (the Cashu proofs are already
committed at that point, regardless of whether the subsequent chat HTTP
call itself succeeds).
- api/rpc/assistant_chat.rs: `assistant.budget-get`/`assistant.budget-set`
RPCs (routed through the existing single `assistant.` dispatcher arm —
dispatcher.rs untouched) and a `nostr_tor_proxy()` accessor for
select_backend's onion-preference decision.
Named tests (assistant::tests::): zero_budget_stops_loop_without_retry (S-12),
zero_allowance_never_selects_routstr, ceiling_is_not_a_function_of_model_output,
injection_loop_against_low_budget_does_not_overspend (EV-17) — all pass.
Full assistant:: suite: 91/91. Full crate suite: 1235/1235 (2 pre-existing
ignored, unrelated). dispatcher.rs and Cargo.toml untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task 1 decision (proceed-docs-with-probe-first, operator-selected via
AskUserQuestion 2026-08-05): 13-ROUTSTR-FINDINGS.md observed 0 of 9 cited
protocol claims (no live provider was announcing on any of the 3 default
relays in a 30s window on 2026-08-03; relay reachability itself WAS
confirmed). This backend is written against docs.routstr.com's cited shape,
with the first live chat-completions call doubling as the capability probe:
a non-success HTTP status or a response missing the expected
choices[0].message shape fails loudly (bails with the real status/body)
rather than silently degrading.
- assistant/backends/routstr.rs (new): RoutstrBackend implements the
Backend trait — discover_providers subscribes for kind-38421
provider-announcement events over the existing Tor-proxy-aware Nostr
client (nostr_discovery::build_nostr_client, never a second relay
client), process-cached with a 5-minute TTL; select_provider picks the
globally cheapest affordable (provider, model) price across every
discovered provider (Routstr has no fixed target model the way
Ollama/Claude do — CONTEXT.md delegates provider selection strategy to
Claude's discretion), preferring an onion endpoint when Tor is up;
attach_payment calls the existing budget-capped auto_pay_token verbatim
(never hand-rolled); parse_openai_tool_calls parses the one
string-encoded function.arguments shape exactly once at this adapter's
edge; screen_outbound (G-B1/G-B2) runs before any body leaves the node,
exactly as it does for Claude; ROUTSTR_MAX_TOKENS caps every request
explicitly.
- assistant/egress.rs: message_is_turn_own gains "system" and "tool" role
handling plus an OpenAI tool_calls-sibling-field check — the pre-existing
function was written only against Claude's wire shape (system as a
top-level field, tool results wrapped in role:"user") and would have
silently stripped Routstr's system prompt and tool-result context out of
every outbound request via G-B2's fail-closed default arm. Fixed with 4
new regression tests pinning both wire shapes.
- assistant/backends/mod.rs: registers `pub mod routstr;`. select_backend's
actual wiring of the Routstr leg (budget-gated, per D-05) is Task 3's
commit, once AssistantBudget exists — this task's own acceptance criteria
do not require select_backend integration, only the adapter itself.
30/30 assistant::backends:: tests pass (17 new in routstr.rs, 3 new in
egress.rs's OpenAI-shape regression tests were run separately at 12/12).
Zero new packages (nostr-sdk/reqwest already in-tree); dispatcher.rs and
Cargo.toml untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
assistant/untrusted.rs: wrap_untrusted(label, text) wraps peer-supplied text
(filenames, log lines, mesh/peer status) in a delimiter block whose token is
freshly randomized on every call via the in-tree rand crate — never a module
constant, never derived from content. A forged closing boundary using a
guessed/fixed token cannot terminate the real block early (EV-11).
tools.rs: wrap_tool_result_if_untrusted wires this in for content_list,
app_logs and mesh_status (the tools whose results carry peer-authored text);
every other tool result passes through unwrapped. loop_.rs's execute_tool
calls it at the exact point a successful ToolResult is constructed, before
that content ever becomes part of a ChatMessage.
No pattern-stripping or keyword-blocklist filter was added (D-10 rejects
that approach by name) — the delimiter and D-11's confirm gate are two
independent layers. Four scripted-worst-case tests in mod.rs prove the gate
still holds even when a compromised model acts on an injected imperative
(injected_instruction_does_not_grant_authority), a forged closing delimiter
plus fake operator turn (forged_closing_delimiter_does_not_escape_block), or
an injected mislabel attempting to hide the real action from the human
(injected_mislabel_still_confirms_real_action) — plus
wrap_untrusted_token_is_per_call (tools.rs) asserting the per-call token
itself. Zero packages added — rand 0.8.5 already in-tree.
56/56 assistant:: tests pass in this task's own isolated state.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
history.rs persists the ChatMessage transcript under data_dir (D-08),
keyed by a HistoryKey derived from CallerScope so an operator's AIUI
session and a mesh peer's transcript are structurally distinct files, not
two rows a filter could forget. Writes are atomic (temp sibling + rename,
matching music/index.rs::save_atomic's precedent) and 0600, following
grants.rs's convention.
Tool results longer than MAX_TOOL_RESULT_CHARS are truncated with a
visible marker before entering history -- a new, assistant-scoped
constant, never assist.rs's LoRa-airtime-tuned reply cap. Once the
transcript exceeds KEEP_VERBATIM_TURNS, older turns fold into a running
summary extended incrementally as turns age out, never regenerated from
the full transcript. Wallet/files-category tool-call arguments are never
persisted (AI-SPEC §7b's field policy applied to storage, not only
tracing) -- categories are resolved by the caller from the same tools
registry execute_tool uses, so history.rs never re-derives a second,
driftable category list. Nothing reachable from confirm.rs's pending-
confirmation state has a parameter path into this module at all (S-09
stays true structurally).
assistant.history / assistant.clear-history route through 13-01's
existing assistant.* dispatcher arm (dispatcher.rs untouched), each
scoped to the calling session's own HistoryKey.
run_loop (loop_.rs) now returns (answer, full_history) instead of just
the answer string -- structurally necessary so chat() (mod.rs) can
persist the tool-call/tool-result messages the loop built internally, not
only the user question and final answer (Rule 3, mirroring 13-05's
precedent of touching a file outside its own plan's files_modified list
when the plan's own intent requires it). chat() persists this turn after
run_loop returns; it does not yet feed prior persisted turns back into
live model context -- a documented, deliberately scoped follow-up (see
mod.rs's chat() doc comment and the plan SUMMARY).
8 new tests under assistant::history::tests::, including
operator_and_mesh_transcripts_are_separate and
wallet_tool_arguments_never_reach_the_transcript (asserted against both
the deserialized struct and the raw on-disk bytes). Full assistant::
suite: 50/50 (42 baseline-after-Task-1 + 8 new); confirm::tests::
restart_drops_pending_not_executes still passes -- S-09 not weakened.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
backends/ollama.rs implements the Backend trait against Ollama's
POST /api/chat (messages + tools arrays, message.tool_calls response) --
never mesh/listener/assist.rs::call_ollama's older single-shot prompt
endpoint, which has no tool-calling support at all. Ollama's per-call
tool-call ids (absent on the wire) are synthesized; its already-parsed
function.arguments object is passed through without a second string-parse
(the OpenAI-shape normalization would be wrong here). Every request sets
an explicit generation-length cap and runs non-streaming.
model_supports_tools queries Ollama's /api/show and caches the answer for
the process lifetime, turning AI-SPEC's [ASSUMED] note about
qwen2.5-coder's tool capability into a runtime fact: a non-tool-capable or
unreachable Ollama falls through to Claude with a logged reason, never a
silent tools-free degrade.
select_backend (backends/mod.rs) is now async and reuses the existing
detect_ollama() probe (mesh::assistant, bumped to pub(crate) for this
reuse) rather than re-probing. A new FallbackChain wraps the Ollama leg so
a transport error mid-turn falls through to Claude for that same call
instead of failing the turn outright.
13 new tests under assistant::backends::{ollama,}::tests::, exercised
against a local hyper-based HTTP stub (no mock-HTTP crate exists in this
workspace). Full assistant:: suite: 42/42 (29 baseline + 13 new).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two more on-device UAT findings:
1. Deny-retry loop: the model, told 'the user declined', simply called the
tool again — each retry minted a fresh pending and re-opened the dialog
(T-13-50 habituation, mechanized). ToolExecCtx now remembers declined
actions for the turn, keyed by confirm::action_key — the same canonical
(tool_name, validated_args) identity the nonce binds — and execute_tool
refuses a re-ask before the gate, minting nothing. Regression test
declined_action_never_reprompts_same_turn.
2. Timeout chain: rpcClient's 15s default aborted every confirmable turn
client-side while the node kept the pending alive — the next turn then
re-announced it (modal over and over) and every wait read as 'timed
out'. assistant.chat now rides a 420s timeout; AIUI's bridge goes
180s→430s so the host's error path (which also expires the dialog)
always fires first. Declined ToolResult text now also tells the model
to stop.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On-device UAT: the operator was timed out mid-read (120s), the chat turn
returned 'declined' while the dialog was still up, and their Approve then
hit a dead entry ('no such pending confirmation', 13:37:12 log). Nothing
executed — the gate failed safe — but the UX was a lie in both directions.
- CONFIRM_TIMEOUT 120s→300s: human-speed per T-13-51's own rubric.
- ContextBroker dispatches aiui:tool-confirm-expired when a pending action
vanishes node-side (poll) or the turn ends; Chat.vue closes the modal on
it. Same host-only CustomEvent discipline; iframe has no path to it.
- Two new tests; 21/21 green across toolConfirm + chatAiuiEmbed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On-device UAT hit an infinite politeness loop: 'every write requires a human
confirmation you cannot bypass' read to the model as 'collect consent in text
first', so it never called restart_app, the confirm gate never engaged, and
each stateless turn (history is 13-10) dropped the user's 'confirmed' into a
void. The preamble now states the intended contract: the node presents the
trusted dialog the moment the tool is called; a text pre-ask stalls the action
and trains rubber-stamping.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Executor stopped deliberately for a session restart (bypass-permissions relaunch).
Executor's final report: 'cargo test assistant confirm-gate suite 28/28 green,
individual nonce test passes; committing Task 1 next — first verify the
tools.rs/grants.rs/backends diffs are formatting-only.'
Task 1 (D-07/D-11 confirm gate, backend) is implemented and test-green but this
checkpoint is verbatim-uncommitted-state, NOT the reviewed atomic Task 1 commit:
continuation executor should verify diffs, then reset --soft or commit-on-top
into proper feat(13-08) task commits. Task 2 (ToolConfirmModal.vue trusted
chrome, Chat.vue + contextBroker.ts wiring) is partially built, tests written.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- confirm.rs: ConfirmGate/PendingConfirmation/Confirmed/PendingSnapshot/
ResolveRefusal API skeleton (request/resolve/mint_nonce/build_description
still todo!()) plus the five named confirm tests: S-02 nonce binding,
S-03 no-model-text, S-08 distinct resources, S-09 restart drops pending,
timeout declines, and the no-shared-lock-across-the-wait case
- mod.rs: ToolExecCtx gains the confirm gate (global by default, injectable
for tests) and the S-01 destructive_tool_requires_confirm test with a
seeded installed-app snapshot
- verified RED: 7 new tests fail (todo! cores + unfilled destructive branch)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task 3: four registry-wide structural tests in tools.rs, iterating registry()
so a future tool that crosses the D-09 ceiling fails CI rather than depending
on a reviewer noticing:
- registry_never_exposes_excluded_authority (S-04/T-13-24): scans every
ToolDef's name+description for EXCLUDED_AUTHORITY_TERMS.
- read_tools_never_confirm (S-07/T-13-31): every non-destructive tool
executes via the real execute_tool choke point without raising anything
confirmation-shaped. bitcoin_status/network_status excluded from live
execution (their handlers make real outbound network calls that would
make this test flaky on a sandboxed box); their destructive:false
placement is still covered by the other assertions.
- loop_is_bounded (S-13/D-05): MAX_TURNS is enforced, and 3 consecutive
malformed-argument calls for the same tool name abort the turn with an
apology before a 4th scripted backend turn is ever polled.
- every_tool_has_explicit_category_and_destructive: sanity-checks the
registry has exactly the 13 hand-written tools (4 destructive) that made
it in, as a runtime backstop to the acceptance criteria's static grep for
`..Default::default()`.
Negative-case demonstration (per the plan's acceptance criteria): a
hypothetical `wallet_send_sats` tool with a description mentioning
"spending sats" trips EXCLUDED_AUTHORITY_TERMS's "spend" term, verified by
tracing the exact haystack-contains logic registry_never_exposes_excluded_authority
runs (see 13-05-SUMMARY.md for why this was traced rather than executed).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 1: registry() grows from the tracer's single system_disk_status tool to
the full 13-tool D-06 curated allowlist (9 read tools, 4 destructive write
tools), each hand-written with its own JSON Schema, PermissionCategory and
destructive flag -- nothing derived from api::rpc's method table. Adds
EXCLUDED_AUTHORITY_TERMS (D-09's excluded authority, scanned by Task 3's
registry-wide test), SETTABLE_KEYS/READABLE_SETTINGS_KEYS (AIUI-02's
hand-picked settings surface, claude_api_key permanently absent from
SETTABLE_KEYS), tools::dispatch (per-tool RPC dispatch) and
tools::validate_business_rules (allowlisted-key / installed-app-id
validation that runs before the destructive/confirm gate so a plainly-wrong
request is refused with the real reason instead of the generic
"not yet implemented" placeholder). assistant_dispatch_tool gains a params
argument and the RPC method table Task 1's tools need.
Task 2: grants.rs adds Grants (D-16 default-closed permission-category
store, persisted 0600 under data_dir/assistant/grants.json; a missing file
is default_closed(), never permissive). CallerScope::granted_categories
becomes async and reads the persisted store instead of a hardcoded default;
CallerScope::Mesh gains an `authorized` field so a mesh peer's ceiling is
never wider than the operator's own grants. ToolExecCtx gains the AI-SPEC
S-13 consecutive-validation-failure counter (>2 failures for the same tool
name aborts the turn with an apology, checked in run_loop). build_system_prompt
appends only currently-granted-category tools' names/descriptions -- an
ungranted tool never appears in the prompt string (defense in depth; the
execute_tool grant re-check is the actual gate). assistant_chat.rs adds
assistant.list-tools / assistant.grants-get / assistant.grants-set, all
routed through the existing single assistant.* dispatcher arm (dispatcher.rs
untouched, verified by git diff --exit-code).
dispatcher.rs is not touched -- all new RPC surface goes through 13-01's
assistant.* prefix arm.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
D-01/D-02/D-06 tracer slice: a new crate::assistant module (CallerScope,
PermissionCategory, ToolExecCtx, chat()) runs a multi-turn tool-calling loop
(run_loop/execute_tool, MAX_TURNS=8) against a curated single-tool registry
(system_disk_status, hand-written JSON Schema — no schemars) via a Claude
Messages API backend. execute_tool is the single choke point: unknown tools
are refused not ignored, D-16 category grants are re-checked even though the
system prompt already omits ungranted tools, and every real tool dispatches
through the SAME handle_system_disk_status RPC handler every other
authenticated caller uses (assistant_dispatch_tool bridge in
api/rpc/assistant_chat.rs) — never an AI-only backdoor.
assistant.chat is registered in dispatcher.rs as a single guarded
`m if m.starts_with("assistant.")` arm reached only after the existing
session-cookie + CSRF + role.can_access() gate in api/rpc/mod.rs — asserted
directly by assistant_methods_require_session against the live
UNAUTHENTICATED_METHODS list (visibility only widened to pub(crate) for that
assertion; the list's contents are untouched, per the Phase-10 hard
constraint).
Key read from data_dir/secrets/claude-api-key — the same path
mesh/rpc/mesh/assistant.rs already probes — never a second key location.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>