2026-08-04 01:28:29 -04:00
//! D-06: a curated, hand-written tool registry. Never derived from the RPC
//! method table in `api::rpc` — every capability the chat has is a
//! deliberate decision recorded here, and the model never sees the full
2026-08-03 14:37:16 -04:00
//! RPC surface. No `schemars` — that crate is absent from `Cargo.toml` and
//! from 13-RESEARCH.md's Package Legitimacy Audit, so `parameters` below is
//! a hand-written JSON Schema object literal instead.
2026-08-04 01:28:29 -04:00
//!
//! D-09's authority ceiling — reads within granted categories, app
//! lifecycle (start/stop/restart), and settings writes; keys, seeds,
//! wallet spends, federation trust and factory reset permanently excluded
//! — is enforced by **absence**: there is no `ToolDef` anywhere below for
//! any of those, and `EXCLUDED_AUTHORITY_TERMS` gives
//! `registry_never_exposes_excluded_authority` (13-05 Task 3) something
//! concrete to assert over so a future out-of-bounds addition fails a
//! test, not a review.
2026-08-03 14:37:16 -04:00
use std ::collections ::{ BTreeSet , HashMap };
use anyhow ::{ Context , Result };
use serde ::Deserialize ;
use serde_json ::{ json , Value };
use super ::PermissionCategory ;
2026-08-04 01:28:29 -04:00
use crate ::api ::rpc ::RpcHandler ;
2026-08-03 14:37:16 -04:00
/// The backend-agnostic in/out of a tool invocation — the same shape
/// regardless of which adapter (Ollama/Claude/Routstr) produced it.
#[derive(Debug, Clone)]
pub struct ToolCall {
pub id : String ,
pub name : String ,
pub arguments : Value ,
}
#[derive(Debug, Clone)]
pub struct ToolResult {
pub call_id : String ,
pub content : String ,
pub is_error : bool ,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
System ,
User ,
Assistant ,
Tool ,
}
#[derive(Debug, Clone)]
pub struct ChatMessage {
pub role : Role ,
/// Plain text, or (a future plan's) D-10-wrapped untrusted content.
pub text : Option < String > ,
/// Assistant-authored tool calls made THIS turn (role: Assistant).
pub tool_calls : Vec < ToolCall > ,
/// Tool results fed back THIS turn (role: Tool).
pub tool_results : Vec < ToolResult > ,
}
/// D-06: one curated, hand-written tool. Never generated from the RPC
2026-08-04 01:28:29 -04:00
/// method table — the curated set IS the D-09 authority boundary.
2026-08-03 14:37:16 -04:00
#[derive(Clone)]
pub struct ToolDef {
pub name : & 'static str ,
pub description : & 'static str ,
/// JSON Schema `{"type":"object","properties":{...},"required":[...]}`,
/// hand-written and pinned adjacent to the args struct it must never
2026-08-04 01:28:29 -04:00
/// drift from — see
/// `every_tool_schema_round_trips_required_keys_into_its_args_struct`.
2026-08-03 14:37:16 -04:00
pub parameters : Value ,
pub category : PermissionCategory ,
2026-08-04 01:28:29 -04:00
/// D-07: true => confirm gate, no exceptions. 13-08 fills in the real
/// confirm flow; `execute_tool` refuses this branch with a
/// not-yet-implemented error until then.
2026-08-03 14:37:16 -04:00
pub destructive : bool ,
}
2026-08-04 01:28:29 -04:00
/// D-09's excluded authority, in one place. `registry_never_exposes_excluded_authority`
/// (Task 3) scans every `ToolDef`'s name and description for these phrases
/// at runtime, over the WHOLE registry, so a tool added in a later phase
/// that crosses the ceiling fails that test. §1b's regulatory rationale:
/// this exclusion is what keeps the software inside the MiCA/GENIUS
/// non-custodial carve-out — relaxing it is a compliance decision, not a
/// code-review nit.
///
/// Deliberately does NOT itself spell out the specific forbidden tool-name
/// identifiers this plan's acceptance criteria grep for directly against
/// the source (their absence from every `ToolDef` literal below is what
/// that grep verifies) — this const is the separate, phrase-based set this
/// file's own test module scans tool text against.
pub const EXCLUDED_AUTHORITY_TERMS : & [ & str ] = & [
"seed" ,
"mnemonic" ,
"private key" ,
"macaroon" ,
"spend" ,
"send sats" ,
"pay invoice" ,
"federation trust" ,
"factory reset" ,
"wipe" ,
];
/// AIUI-02's settable-key allowlist. Hand-picked from the surfaces that
/// actually exist (13-05-PLAN.md's Task 1 action) — `claude_api_key` is
/// deliberately **not** here: it is key material, D-09 puts keys
/// permanently outside chat reach, and being the only key
/// `system.settings.set` accepts today is not a reason to include it.
pub const SETTABLE_KEYS : & [ & str ] = & [
"network_visibility" ,
"kiosk_display_preset" ,
"wifi_radio" ,
"bitcoin_relay_settings" ,
];
/// `settings_get`'s read-side allowlist — deliberately separate from
/// `SETTABLE_KEYS` (a key can be safely readable, like whether the Claude
/// key is *set*, without being safely writable or without exposing the
/// key material itself).
2026-08-05 11:34:31 -04:00
pub const READABLE_SETTINGS_KEYS : & [ & str ] = & [
"network_visibility" ,
"kiosk_display_preset" ,
"claude_api_key_set" ,
];
2026-08-04 01:28:29 -04:00
/// Args for tools that take no parameters at all
/// (`system_disk_status`, `system_stats`, `apps_list`, `bitcoin_status`,
/// `network_status`, `mesh_status`, `content_list`).
2026-08-03 14:37:16 -04:00
#[derive(Debug, Deserialize)]
pub struct SystemDiskStatusArgs {}
2026-08-04 01:28:29 -04:00
/// Args for `app_start` / `app_stop` / `app_restart` — an exact installed
/// app id, never fuzzy-matched.
#[derive(Debug, Deserialize)]
pub struct AppIdArgs {
pub app_id : String ,
}
/// Args for `app_logs` — an exact app id, plus an optional line count
/// capped at 200 regardless of what the model asks for.
#[derive(Debug, Deserialize)]
pub struct AppLogsArgs {
pub app_id : String ,
#[serde(default)]
pub lines : Option < u64 > ,
}
/// Args for `settings_get` — a key from `READABLE_SETTINGS_KEYS`.
#[derive(Debug, Deserialize)]
pub struct SettingsGetArgs {
pub key : String ,
}
/// Args for `settings_set` — a key from `SETTABLE_KEYS`, plus a value
/// whose JSON type depends on the key (string, bool, or object — see each
/// key's schema description). Kept as a raw `Value` rather than a fixed
/// Rust type because the settable keys deliberately span different value
/// shapes; `dispatch` below validates the shape per key before use.
#[derive(Debug, Deserialize)]
pub struct SettingsSetArgs {
pub key : String ,
pub value : Value ,
}
/// The deserialized, schema-validated form of a tool call's arguments —
/// `ToolDef::validate`'s return type. One variant per distinct args shape
/// in the registry (several read-only, no-arg tools share
/// `Empty(SystemDiskStatusArgs)`).
pub enum ToolArgs {
Empty ( SystemDiskStatusArgs ),
AppId ( AppIdArgs ),
AppLogs ( AppLogsArgs ),
SettingsGet ( SettingsGetArgs ),
SettingsSet ( SettingsSetArgs ),
}
2026-08-03 14:37:16 -04:00
impl ToolDef {
/// Deserialize + validate model-produced arguments before ANY
/// execution. Never coerce, never guess, never panic on a mismatch —
/// refuse and let the caller turn the error into a tool result the
2026-08-04 01:28:29 -04:00
/// model can recover from (AI-SPEC §4b.1).
pub fn validate ( & self , raw : & Value ) -> Result < ToolArgs > {
match self . name {
"system_disk_status" | "system_stats" | "apps_list" | "bitcoin_status"
2026-08-05 11:34:31 -04:00
| "network_status" | "mesh_status" | "content_list" => {
serde_json ::from_value ( raw . clone ())
. map ( ToolArgs ::Empty )
. context ( "tool arguments did not match the declared schema" )
}
2026-08-04 01:28:29 -04:00
"app_logs" => serde_json ::from_value ( raw . clone ())
. map ( ToolArgs ::AppLogs )
. context ( "tool arguments did not match the declared schema" ),
"app_start" | "app_stop" | "app_restart" => serde_json ::from_value ( raw . clone ())
. map ( ToolArgs ::AppId )
. context ( "tool arguments did not match the declared schema" ),
"settings_get" => serde_json ::from_value ( raw . clone ())
. map ( ToolArgs ::SettingsGet )
. context ( "tool arguments did not match the declared schema" ),
"settings_set" => serde_json ::from_value ( raw . clone ())
. map ( ToolArgs ::SettingsSet )
. context ( "tool arguments did not match the declared schema" ),
other => anyhow ::bail! ( "no validator registered for tool: {other}" ),
}
2026-08-03 14:37:16 -04:00
}
}
2026-08-04 01:28:29 -04:00
// ---------------------------------------------------------------------
// Read tools (destructive: false)
// ---------------------------------------------------------------------
2026-08-03 14:37:16 -04:00
/// `system_disk_status` — category `System`, read-only. Reports free and
/// total disk space on this node via the same `system.disk-status` handler
/// every other authenticated caller uses.
pub fn system_disk_status_tool () -> ToolDef {
ToolDef {
name : "system_disk_status" ,
description : "Report free and total disk space on this Archipelago node." ,
parameters : json ! ({
"type" : "object" ,
"properties" : {},
"required" : [],
}),
category : PermissionCategory ::System ,
destructive : false ,
}
}
2026-08-04 01:28:29 -04:00
/// `system_stats` — category `System`, read-only. CPU, RAM, disk, uptime,
/// load average.
pub fn system_stats_tool () -> ToolDef {
ToolDef {
name : "system_stats" ,
description : "Report CPU usage, RAM used/total, disk used/total, uptime and load average on this node." ,
parameters : json ! ({
"type" : "object" ,
"properties" : {},
"required" : [],
}),
category : PermissionCategory ::System ,
destructive : false ,
}
}
/// `apps_list` — category `Apps`, read-only. Installed app ids and state.
pub fn apps_list_tool () -> ToolDef {
ToolDef {
name : "apps_list" ,
description : "List installed apps on this node with their id and current state (running/stopped/exited/etc)." ,
parameters : json ! ({
"type" : "object" ,
"properties" : {},
"required" : [],
}),
category : PermissionCategory ::Apps ,
destructive : false ,
}
}
/// `app_logs` — category `Apps`, read-only. Recent log lines for one app.
pub fn app_logs_tool () -> ToolDef {
ToolDef {
name : "app_logs" ,
description : "Fetch recent log lines for one installed app, by its exact app id (never guess or fuzzy-match an id — call apps_list first if unsure). \
Example: { \" app_id \" : \" bitcoin-core \" , \" lines \" : 50}." ,
parameters : json ! ({
"type" : "object" ,
"properties" : {
"app_id" : { "type" : "string" , "description" : "Exact installed app id, e.g. \" bitcoin-core \" ." },
"lines" : { "type" : "integer" , "description" : "Number of trailing log lines to return; capped at 200 regardless of what is requested." },
},
"required" : [ "app_id" ],
}),
category : PermissionCategory ::Apps ,
destructive : false ,
}
}
/// `bitcoin_status` — category `Bitcoin`, read-only. Block height, sync
/// progress, mempool stats — no wallet keys.
pub fn bitcoin_status_tool () -> ToolDef {
ToolDef {
name : "bitcoin_status" ,
description : "Report this node's Bitcoin sync status: block height, sync progress and mempool stats. Never returns wallet balances, addresses or keys." ,
parameters : json ! ({
"type" : "object" ,
"properties" : {},
"required" : [],
}),
category : PermissionCategory ::Bitcoin ,
destructive : false ,
}
}
/// `network_status` — category `Network`, read-only. Visibility +
/// diagnostics — no IP addresses beyond what diagnostics already surfaces
/// to the operator elsewhere in the UI.
pub fn network_status_tool () -> ToolDef {
ToolDef {
name : "network_status" ,
description : "Report this node's network visibility setting and connectivity diagnostics (NAT type, UPnP, Tor connectivity, DNS)." ,
parameters : json ! ({
"type" : "object" ,
"properties" : {},
"required" : [],
}),
category : PermissionCategory ::Network ,
destructive : false ,
}
}
/// `mesh_status` — category `Network`, read-only. Mesh radio status,
/// device info, peer count.
pub fn mesh_status_tool () -> ToolDef {
ToolDef {
name : "mesh_status" ,
description : "Report this node's mesh (LoRa/Meshtastic) radio status: whether mesh is enabled, device detection, and peer count." ,
parameters : json ! ({
"type" : "object" ,
"properties" : {},
"required" : [],
}),
category : PermissionCategory ::Network ,
destructive : false ,
}
}
/// `content_list` — category `Media`, read-only. Content this node is
/// sharing.
pub fn content_list_tool () -> ToolDef {
ToolDef {
name : "content_list" ,
description : "List content this node is currently sharing (filename, mime type, size, access level). Never returns file contents." ,
parameters : json ! ({
"type" : "object" ,
"properties" : {},
"required" : [],
}),
category : PermissionCategory ::Media ,
destructive : false ,
}
}
/// `settings_get` — category `System`, read-only, behind
/// `READABLE_SETTINGS_KEYS`.
pub fn settings_get_tool () -> ToolDef {
ToolDef {
name : "settings_get" ,
description : "Read one node setting by key. Only a hand-picked set of keys is readable this way: network_visibility, kiosk_display_preset, claude_api_key_set (whether a Claude API key is configured — never the key itself). \
Example: { \" key \" : \" network_visibility \" }." ,
parameters : json ! ({
"type" : "object" ,
"properties" : {
"key" : { "type" : "string" , "description" : "One of: network_visibility, kiosk_display_preset, claude_api_key_set." },
},
"required" : [ "key" ],
}),
category : PermissionCategory ::System ,
destructive : false ,
}
}
// ---------------------------------------------------------------------
// Write tools (every entry below is marked destructive)
// ---------------------------------------------------------------------
/// `app_start` — category `Apps`, destructive (changes node state; still
/// gated by the D-07 confirm flow once 13-08 lands).
pub fn app_start_tool () -> ToolDef {
ToolDef {
name : "app_start" ,
description : "Start an installed app by its EXACT app id — never fuzzy-matched or guessed; ask the user which app if unsure, or call apps_list first. \
Example: { \" app_id \" : \" bitcoin-core \" }." ,
parameters : json ! ({
"type" : "object" ,
"properties" : {
"app_id" : { "type" : "string" , "description" : "Exact installed app id, e.g. \" bitcoin-core \" ." },
},
"required" : [ "app_id" ],
}),
category : PermissionCategory ::Apps ,
destructive : true ,
}
}
/// `app_stop` — category `Apps`, destructive.
pub fn app_stop_tool () -> ToolDef {
ToolDef {
name : "app_stop" ,
description : "Stop an installed app by its EXACT app id — never fuzzy-matched or guessed; ask the user which app if unsure, or call apps_list first. \
Example: { \" app_id \" : \" bitcoin-core \" }." ,
parameters : json ! ({
"type" : "object" ,
"properties" : {
"app_id" : { "type" : "string" , "description" : "Exact installed app id, e.g. \" bitcoin-core \" ." },
},
"required" : [ "app_id" ],
}),
category : PermissionCategory ::Apps ,
destructive : true ,
}
}
/// `app_restart` — category `Apps`, destructive. EV-08's "restart the
/// node" case: this tool restarts ONE app, and refuses an id that does not
/// exactly match an installed app rather than guessing which one was
/// meant.
pub fn app_restart_tool () -> ToolDef {
ToolDef {
name : "app_restart" ,
description : "Restart an installed app by its EXACT app id — never fuzzy-matched or guessed. If the user says something like \" restart the node \" without naming an app, ask which app (there is no single \" restart everything \" tool) rather than guessing. \
Example: { \" app_id \" : \" bitcoin-core \" }." ,
parameters : json ! ({
"type" : "object" ,
"properties" : {
"app_id" : { "type" : "string" , "description" : "Exact installed app id, e.g. \" bitcoin-core \" ." },
},
"required" : [ "app_id" ],
}),
category : PermissionCategory ::Apps ,
destructive : true ,
}
}
/// `settings_set` — category `System`, destructive. AIUI-02's surface,
/// bounded by `SETTABLE_KEYS`. `claude_api_key` is permanently excluded —
/// see the module doc comment and D-09.
pub fn settings_set_tool () -> ToolDef {
ToolDef {
name : "settings_set" ,
description : "Change one node setting by key. Only a hand-picked set of keys is settable this way: network_visibility (string: hidden|discoverable|public), kiosk_display_preset (string), wifi_radio (boolean), bitcoin_relay_settings (object). \
claude_api_key is NEVER settable here — refuse and point at neode-ui's Settings screen if asked. \
Example: { \" key \" : \" wifi_radio \" , \" value \" : false}." ,
parameters : json ! ({
"type" : "object" ,
"properties" : {
"key" : { "type" : "string" , "description" : "One of: network_visibility, kiosk_display_preset, wifi_radio, bitcoin_relay_settings." },
"value" : { "description" : "Type depends on key: string for network_visibility/kiosk_display_preset, boolean for wifi_radio, object for bitcoin_relay_settings." },
},
"required" : [ "key" , "value" ],
}),
category : PermissionCategory ::System ,
destructive : true ,
}
}
/// D-06's curated registry, name-indexed.
2026-08-03 14:37:16 -04:00
pub struct ToolRegistry {
tools : HashMap <& 'static str , ToolDef > ,
}
impl ToolRegistry {
pub fn get ( & self , name : & str ) -> Option <& ToolDef > {
self . tools . get ( name )
}
/// The subset of the registry visible to a caller with `grants`. D-16:
/// an unconfigured node's system prompt should advertise close to zero
/// tools — the model should never even see a tool it can't use.
pub fn visible_to ( & self , grants : & BTreeSet < PermissionCategory > ) -> Vec < ToolDef > {
self . tools
. values ()
. filter ( | t | grants . contains ( & t . category ))
. cloned ()
. collect ()
}
2026-08-04 01:28:29 -04:00
/// Every registered tool, regardless of grants — for registry-wide
/// structural assertions (Task 3) and for `assistant.list-tools`'
/// underlying data before it is filtered to what is granted.
pub fn all ( & self ) -> Vec < ToolDef > {
self . tools . values (). cloned (). collect ()
}
2026-08-03 14:37:16 -04:00
}
2026-08-04 01:28:29 -04:00
/// D-06's curated allowlist. Every entry below is a hand-written decision
/// — nothing here is derived from `api::rpc`'s method table.
2026-08-03 14:37:16 -04:00
pub fn registry () -> ToolRegistry {
let mut tools = HashMap ::new ();
2026-08-04 01:28:29 -04:00
for tool in [
system_disk_status_tool (),
system_stats_tool (),
apps_list_tool (),
app_logs_tool (),
bitcoin_status_tool (),
network_status_tool (),
mesh_status_tool (),
content_list_tool (),
settings_get_tool (),
app_start_tool (),
app_stop_tool (),
app_restart_tool (),
settings_set_tool (),
] {
tools . insert ( tool . name , tool );
}
2026-08-03 14:37:16 -04:00
ToolRegistry { tools }
}
2026-08-04 01:28:29 -04:00
/// Resolve `app_id` against the SAME `container-list` handler every other
/// authenticated caller uses. Refuses an id that is not an EXACT match —
/// no fuzzy match, no nearest-neighbour (EV-08 / T-13-27) — and the
/// refusal lists the installed ids instead of guessing.
async fn resolve_installed_app_id ( app_id : & str , handler : & RpcHandler ) -> Result < String , String > {
let list = handler
. assistant_dispatch_tool ( "container-list" , None )
. await
. map_err ( | e | format! ( "could not list installed apps: {e} " )) ? ;
let ids : Vec < String > = list
. as_array ()
. map ( | arr | {
arr . iter ()
. filter_map ( | v | v . get ( "id" ). and_then ( | i | i . as_str ()). map ( String ::from ))
. collect ()
})
. unwrap_or_default ();
if ids . iter (). any ( | id | id == app_id ) {
Ok ( app_id . to_string ())
} else if ids . is_empty () {
Err ( format! (
"no such app id: \" {app_id} \" — this node has no installed apps to restart/start/stop right now"
))
} else {
Err ( format! (
"no such app id: \" {app_id} \" . Installed apps: {} " ,
ids . join ( ", " )
))
}
}
/// Business-rule validation for a tool call: whether THIS specific
/// request is well-formed enough to act on at all — an allowlisted
/// settings key, an app id that actually exists — independent of whether
/// the D-07 confirm gate (13-08) has cleared it yet. Never mutates
/// anything (a read-only `container-list` lookup is the only RPC call it
/// makes, for `app_start`/`app_stop`/`app_restart`'s id resolution).
///
/// `execute_tool` (`loop_.rs`) calls this BEFORE the destructive/confirm
/// gate, specifically so a plainly-wrong request (an unlisted settings
/// key, `claude_api_key`, an unknown app id) is refused with the real
/// reason instead of being swallowed by the destructive branch's generic
/// "not yet implemented" placeholder — Task 1's `<done>` criterion ("a
/// settings key outside the allowlist and an app id that does not exist
/// are both refused with a message that names the real path"). `dispatch`
/// below also calls this first, so it stays correct and self-contained
/// once 13-08 wires the real confirm-then-execute flow in and this stops
/// being called from two places.
2026-08-05 11:34:31 -04:00
pub async fn validate_business_rules (
name : & str ,
args : & ToolArgs ,
handler : & RpcHandler ,
) -> Result < (), String > {
2026-08-04 01:28:29 -04:00
match name {
"settings_set" => {
let ToolArgs ::SettingsSet ( a ) = args else {
return Err ( "internal error: args/tool mismatch for settings_set" . to_string ());
};
if a . key == "claude_api_key" {
return Err (
"claude_api_key is key material and is permanently excluded from chat \
reach (D-09). Change it in neode-ui's Settings screen instead."
. to_string (),
);
}
if ! SETTABLE_KEYS . contains ( & a . key . as_str ()) {
return Err ( format! (
" \" {} \" is not settable via chat. Settable keys: {} . Use neode-ui's Settings \
screen for anything else." ,
a . key ,
SETTABLE_KEYS . join ( ", " )
));
}
match a . key . as_str () {
"network_visibility" | "kiosk_display_preset" => {
if a . value . as_str (). is_none () {
return Err ( format! ( " {} 's value must be a string" , a . key ));
}
}
"wifi_radio" => {
if a . value . as_bool (). is_none () {
return Err ( "wifi_radio's value must be a boolean" . to_string ());
}
}
"bitcoin_relay_settings" => {
if ! a . value . is_object () {
return Err ( "bitcoin_relay_settings's value must be an object" . to_string ());
}
}
_ => {}
}
Ok (())
}
"app_start" | "app_stop" | "app_restart" => {
let ToolArgs ::AppId ( a ) = args else {
return Err ( format! ( "internal error: args/tool mismatch for {name} " ));
};
2026-08-05 11:34:31 -04:00
resolve_installed_app_id ( & a . app_id , handler )
. await
. map ( | _ | ())
2026-08-04 01:28:29 -04:00
}
_ => Ok (()),
}
}
/// D-06's per-tool dispatch: the hand-written decision of which RPC method
/// (if any) a given tool name reaches, and what params to build for it.
/// Every arm below calls into `RpcHandler::assistant_dispatch_tool`, which
/// bridges into the SAME method every other authenticated caller uses —
/// never a parallel AI-only path (see `api/rpc/assistant_chat.rs`).
///
/// Returns `Err(String)` (not `anyhow::Error`) because every error path
/// here is meant to become the tool result's `content` verbatim — a
/// message the model (and, through it, the user) can read and act on, not
/// an internal diagnostic.
pub async fn dispatch ( name : & str , args : & ToolArgs , handler : & RpcHandler ) -> Result < Value , String > {
validate_business_rules ( name , args , handler ). await ? ;
match name {
"system_disk_status" => handler
. assistant_dispatch_tool ( "system.disk-status" , None )
. await
. map_err ( | e | format! ( "tool execution failed: {e} " )),
"system_stats" => handler
. assistant_dispatch_tool ( "system.stats" , None )
. await
. map_err ( | e | format! ( "tool execution failed: {e} " )),
"apps_list" => handler
. assistant_dispatch_tool ( "container-list" , None )
. await
. map_err ( | e | format! ( "tool execution failed: {e} " )),
"app_logs" => {
let ToolArgs ::AppLogs ( a ) = args else {
return Err ( "internal error: args/tool mismatch for app_logs" . to_string ());
};
let lines = a . lines . unwrap_or ( 100 ). min ( 200 );
let params = json! ({ "app_id" : a . app_id , "lines" : lines });
handler
. assistant_dispatch_tool ( "container-logs" , Some ( params ))
. await
. map_err ( | e | format! ( "tool execution failed: {e} " ))
}
"bitcoin_status" => handler
. assistant_dispatch_tool ( "bitcoin.getinfo" , None )
. await
. map_err ( | e | format! ( "tool execution failed: {e} " )),
"network_status" => {
let visibility = handler
. assistant_dispatch_tool ( "network.get-visibility" , None )
. await
. map_err ( | e | format! ( "tool execution failed: {e} " )) ? ;
let diagnostics = handler
. assistant_dispatch_tool ( "network.diagnostics" , None )
. await
. map_err ( | e | format! ( "tool execution failed: {e} " )) ? ;
Ok ( json! ({ "visibility" : visibility , "diagnostics" : diagnostics }))
}
"mesh_status" => handler
. assistant_dispatch_tool ( "mesh.status" , None )
. await
. map_err ( | e | format! ( "tool execution failed: {e} " )),
"content_list" => handler
. assistant_dispatch_tool ( "content.list-mine" , None )
. await
. map_err ( | e | format! ( "tool execution failed: {e} " )),
"settings_get" => {
let ToolArgs ::SettingsGet ( a ) = args else {
return Err ( "internal error: args/tool mismatch for settings_get" . to_string ());
};
if ! READABLE_SETTINGS_KEYS . contains ( & a . key . as_str ()) {
return Err ( format! (
" \" {} \" is not readable via chat. Readable keys: {} ." ,
a . key ,
READABLE_SETTINGS_KEYS . join ( ", " )
));
}
match a . key . as_str () {
"network_visibility" => handler
. assistant_dispatch_tool ( "network.get-visibility" , None )
. await
. map_err ( | e | format! ( "tool execution failed: {e} " )),
"kiosk_display_preset" => handler
. assistant_dispatch_tool ( "system.kiosk-display.get" , None )
. await
. map_err ( | e | format! ( "tool execution failed: {e} " )),
"claude_api_key_set" => handler
. assistant_dispatch_tool (
"system.settings.get" ,
Some ( json! ({ "key" : "claude_api_key_set" })),
)
. await
. map_err ( | e | format! ( "tool execution failed: {e} " )),
other => Err ( format! ( "internal error: unhandled readable key {other} " )),
}
}
"settings_set" => {
let ToolArgs ::SettingsSet ( a ) = args else {
return Err ( "internal error: args/tool mismatch for settings_set" . to_string ());
};
if a . key == "claude_api_key" {
return Err (
"claude_api_key is key material and is permanently excluded from chat \
reach (D-09). Change it in neode-ui's Settings screen instead."
. to_string (),
);
}
if ! SETTABLE_KEYS . contains ( & a . key . as_str ()) {
return Err ( format! (
" \" {} \" is not settable via chat. Settable keys: {} . Use neode-ui's Settings \
screen for anything else." ,
a . key ,
SETTABLE_KEYS . join ( ", " )
));
}
match a . key . as_str () {
"network_visibility" => {
let visibility = a
. value
. as_str ()
. ok_or_else ( || "network_visibility's value must be a string" . to_string ()) ? ;
handler
. assistant_dispatch_tool (
"network.set-visibility" ,
Some ( json! ({ "visibility" : visibility })),
)
. await
. map_err ( | e | format! ( "tool execution failed: {e} " ))
}
"kiosk_display_preset" => {
2026-08-05 11:34:31 -04:00
let preset = a . value . as_str (). ok_or_else ( || {
"kiosk_display_preset's value must be a string" . to_string ()
}) ? ;
2026-08-04 01:28:29 -04:00
handler
. assistant_dispatch_tool (
"system.kiosk-display.set" ,
Some ( json! ({ "preset" : preset })),
)
. await
. map_err ( | e | format! ( "tool execution failed: {e} " ))
}
"wifi_radio" => {
let enabled = a
. value
. as_bool ()
. ok_or_else ( || "wifi_radio's value must be a boolean" . to_string ()) ? ;
handler
. assistant_dispatch_tool (
"network.set-wifi-radio" ,
Some ( json! ({ "enabled" : enabled })),
)
. await
. map_err ( | e | format! ( "tool execution failed: {e} " ))
}
"bitcoin_relay_settings" => {
if ! a . value . is_object () {
return Err ( "bitcoin_relay_settings's value must be an object" . to_string ());
}
handler
2026-08-05 11:34:31 -04:00
. assistant_dispatch_tool (
"bitcoin.relay-update-settings" ,
Some ( a . value . clone ()),
)
2026-08-04 01:28:29 -04:00
. await
. map_err ( | e | format! ( "tool execution failed: {e} " ))
}
other => Err ( format! ( "internal error: unhandled settable key {other} " )),
}
}
"app_start" => {
let ToolArgs ::AppId ( a ) = args else {
return Err ( "internal error: args/tool mismatch for app_start" . to_string ());
};
let resolved = resolve_installed_app_id ( & a . app_id , handler ). await ? ;
handler
. assistant_dispatch_tool ( "container-start" , Some ( json! ({ "app_id" : resolved })))
. await
. map_err ( | e | format! ( "tool execution failed: {e} " ))
}
"app_stop" => {
let ToolArgs ::AppId ( a ) = args else {
return Err ( "internal error: args/tool mismatch for app_stop" . to_string ());
};
let resolved = resolve_installed_app_id ( & a . app_id , handler ). await ? ;
handler
. assistant_dispatch_tool ( "container-stop" , Some ( json! ({ "app_id" : resolved })))
. await
. map_err ( | e | format! ( "tool execution failed: {e} " ))
}
"app_restart" => {
let ToolArgs ::AppId ( a ) = args else {
return Err ( "internal error: args/tool mismatch for app_restart" . to_string ());
};
let resolved = resolve_installed_app_id ( & a . app_id , handler ). await ? ;
handler
. assistant_dispatch_tool ( "container-restart" , Some ( json! ({ "app_id" : resolved })))
. await
. map_err ( | e | format! ( "tool execution failed: {e} " ))
}
other => Err ( format! ( "no execution wired for tool: {other} " )),
}
}
2026-08-03 14:37:16 -04:00
#[cfg(test)]
mod tests {
use super ::* ;
2026-08-04 01:28:29 -04:00
use crate ::api ::rpc ::RpcHandler ;
2026-08-04 01:34:43 -04:00
use crate ::assistant ::backends ::scripted ::ScriptedBackend ;
use crate ::assistant ::backends ::BackendTurn ;
use crate ::assistant ::loop_ ::{ execute_tool , run_loop , MAX_TURNS };
2026-08-04 01:28:29 -04:00
use crate ::assistant ::{ CallerScope , ToolExecCtx };
use std ::sync ::Arc ;
/// A minimal but real `RpcHandler` for tests, matching
/// `loop_::tests::test_rpc_handler` — a fresh temp `data_dir`, no
/// orchestrator.
async fn test_rpc_handler () -> ( Arc < RpcHandler > , tempfile ::TempDir ) {
let tmp = tempfile ::tempdir (). expect ( "tempdir" );
let mut config = crate ::config ::Config ::default ();
config . data_dir = tmp . path (). to_path_buf ();
let state_manager = Arc ::new ( crate ::state ::StateManager ::new ());
let metrics_store = Arc ::new ( crate ::monitoring ::MetricsStore ::new ());
let session_store =
crate ::session ::SessionStore ::new_for_tests ( tmp . path (). join ( "sessions.json" ));
let handler = RpcHandler ::new (
config ,
state_manager ,
metrics_store ,
session_store ,
None ,
None ,
)
. await
. expect ( "RpcHandler::new" );
( Arc ::new ( handler ), tmp )
}
async fn grant_all ( handler : & Arc < RpcHandler > ) {
let mut g = crate ::assistant ::grants ::Grants ::load ( handler . data_dir ()). await ;
for category in PermissionCategory ::ALL {
g . set ( category , true );
}
g . save ( handler . data_dir ()). await . expect ( "save grants" );
}
fn local_operator_ctx ( handler : Arc < RpcHandler > ) -> ToolExecCtx {
ToolExecCtx ::new (
registry (),
CallerScope ::LocalOperator {
session_id : "test-session" . to_string (),
},
handler ,
)
}
2026-08-03 14:37:16 -04:00
/// The schema sent to the model and the struct used to deserialize its
2026-08-04 01:28:29 -04:00
/// output must never silently drift apart, for EVERY tool in the
/// registry — not just the tracer's original one.
2026-08-03 14:37:16 -04:00
#[test]
2026-08-04 01:28:29 -04:00
fn every_tool_schema_round_trips_required_keys_into_its_args_struct () {
let reg = registry ();
for tool in reg . all () {
let required = tool
. parameters
. get ( "required" )
. and_then ( | r | r . as_array ())
. cloned ()
. unwrap_or_default ();
2026-08-05 11:34:31 -04:00
let properties = tool
. parameters
. get ( "properties" )
. cloned ()
. unwrap_or_else ( || json! ({}));
2026-08-03 14:37:16 -04:00
2026-08-04 01:28:29 -04:00
let mut obj = serde_json ::Map ::new ();
for key in & required {
if let Some ( k ) = key . as_str () {
let prop_schema = properties . get ( k ). cloned (). unwrap_or_else ( || json! ({}));
let dummy = match prop_schema . get ( "type" ). and_then ( | t | t . as_str ()) {
Some ( "string" ) => json! ( "" ),
Some ( "integer" ) | Some ( "number" ) => json! ( 0 ),
Some ( "boolean" ) => json! ( false ),
Some ( "object" ) => json! ({}),
_ => json! ( "" ),
};
obj . insert ( k . to_string (), dummy );
}
2026-08-03 14:37:16 -04:00
}
2026-08-04 01:28:29 -04:00
let value = Value ::Object ( obj );
let parsed = tool . validate ( & value );
assert! (
parsed . is_ok (),
"schema/args struct drift for tool {}: {:?}" ,
tool . name ,
parsed . err ()
);
2026-08-03 14:37:16 -04:00
}
}
#[test]
fn registry_visible_to_respects_grants () {
let reg = registry ();
let mut grants = BTreeSet ::new ();
assert! ( reg . visible_to ( & grants ). is_empty ());
grants . insert ( PermissionCategory ::System );
2026-08-04 01:28:29 -04:00
// System category currently has 4 tools: system_disk_status,
// system_stats, settings_get, settings_set (destructive is a
// separate axis from category — visible_to filters on category
// only, same as the real system-prompt/list-tools filtering).
assert_eq! ( reg . visible_to ( & grants ). len (), 4 );
2026-08-03 14:37:16 -04:00
}
2026-08-04 01:28:29 -04:00
#[test]
fn settable_keys_never_include_claude_api_key () {
assert! (
! SETTABLE_KEYS . contains ( & "claude_api_key" ),
"claude_api_key must never appear in SETTABLE_KEYS (D-09 — key material stays chat-unreachable)"
);
}
/// S-05 (T-13-26): an ungranted category is refused at `execute_tool`
/// even when the tool was somehow proposed anyway — the system prompt
/// omitting it is defense in depth, never the gate. Exercises the SAME
/// choke point the real loop calls (`loop_::execute_tool`), not a
/// reimplementation.
#[tokio::test]
async fn settings_tool_respects_category_grant () {
let ( handler , _tmp ) = test_rpc_handler (). await ;
// Deliberately do NOT grant System — settings_set is System-scoped.
let ctx = local_operator_ctx ( handler );
let call = ToolCall {
id : "call-1" . to_string (),
name : "settings_set" . to_string (),
arguments : json ! ({ "key" : "wifi_radio" , "value" : true }),
};
let result = execute_tool ( & call , & ctx ). await ;
2026-08-05 11:34:31 -04:00
assert! (
result . is_error ,
"settings_set must be refused when System is not granted"
);
2026-08-04 01:28:29 -04:00
assert! (
result . content . contains ( "not permitted" ),
"expected a not-permitted refusal, got: {}" ,
result . content
);
}
#[tokio::test]
async fn settings_set_refuses_claude_api_key_by_name () {
let ( handler , _tmp ) = test_rpc_handler (). await ;
grant_all ( & handler ). await ;
let ctx = local_operator_ctx ( handler );
let call = ToolCall {
id : "call-1" . to_string (),
name : "settings_set" . to_string (),
arguments : json ! ({ "key" : "claude_api_key" , "value" : "sk-whatever" }),
};
let result = execute_tool ( & call , & ctx ). await ;
assert! ( result . is_error );
assert! (
result . content . to_lowercase (). contains ( "neode-ui" ),
"refusal must name the real neode-ui Settings path, got: {}" ,
result . content
);
}
#[tokio::test]
async fn settings_set_refuses_unlisted_key () {
let ( handler , _tmp ) = test_rpc_handler (). await ;
grant_all ( & handler ). await ;
let ctx = local_operator_ctx ( handler );
let call = ToolCall {
id : "call-1" . to_string (),
name : "settings_set" . to_string (),
arguments : json ! ({ "key" : "totally_made_up_key" , "value" : "x" }),
};
let result = execute_tool ( & call , & ctx ). await ;
assert! ( result . is_error );
assert! (
result . content . contains ( "network_visibility" ),
"refusal must name the settable keys, got: {}" ,
result . content
);
}
#[tokio::test]
async fn app_restart_refuses_unknown_app_id () {
let ( handler , _tmp ) = test_rpc_handler (). await ;
grant_all ( & handler ). await ;
let ctx = local_operator_ctx ( handler );
let call = ToolCall {
id : "call-1" . to_string (),
name : "app_restart" . to_string (),
arguments : json ! ({ "app_id" : "definitely-not-installed" }),
};
let result = execute_tool ( & call , & ctx ). await ;
2026-08-05 11:34:31 -04:00
assert! (
result . is_error ,
"an unknown app id must be refused, not guessed at"
);
2026-08-04 01:28:29 -04:00
assert! (
result . content . contains ( "no such app id" ),
"got: {}" ,
result . content
);
}
2026-08-04 01:34:43 -04:00
/// S-04 / T-13-24: no `ToolDef` in the registry exposes excluded
/// authority, by NAME OR DESCRIPTION, over the WHOLE registry — so a
/// tool added in a later phase that crosses the D-09 ceiling fails
/// this test rather than depending on a reviewer noticing.
#[test]
fn registry_never_exposes_excluded_authority () {
let reg = registry ();
for tool in reg . all () {
let haystack = format! ( " {} {} " , tool . name , tool . description ). to_lowercase ();
for term in EXCLUDED_AUTHORITY_TERMS {
assert! (
! haystack . contains ( & term . to_lowercase ()),
"tool {} exposes excluded authority term {:?} (D-09 ceiling violated)" ,
tool . name ,
term
);
}
}
}
/// S-07 / T-13-31: a read (non-destructive) tool never raises a
/// confirmation request. `bitcoin_status` and `network_status` are
/// excluded from LIVE execution here — their handlers make real
/// outbound network calls (bitcoind RPC / WAN-IP probing / DNS) that
/// would make this test flaky and slow on a sandboxed/offline test
/// box. Their `destructive: false` placement (and thus never hitting
/// the D-07 confirm branch) is still covered by
/// `registry_never_exposes_excluded_authority` and by construction —
/// no confirmation mechanism exists in `execute_tool` for anything
/// other than the `tool.destructive` branch, which those two tools
/// never reach.
#[tokio::test]
async fn read_tools_never_confirm () {
let ( handler , _tmp ) = test_rpc_handler (). await ;
grant_all ( & handler ). await ;
let ctx = local_operator_ctx ( handler );
let reg = registry ();
let network_bound = [ "bitcoin_status" , "network_status" ];
for tool in reg . all () {
if tool . destructive || network_bound . contains ( & tool . name ) {
continue ;
}
let arguments = match tool . name {
"app_logs" => json! ({ "app_id" : "no-such-app" , "lines" : 10 }),
"settings_get" => json! ({ "key" : "network_visibility" }),
_ => json! ({}),
};
let call = ToolCall {
id : format ! ( "call-{}" , tool . name ),
name : tool . name . to_string (),
arguments ,
};
let result = execute_tool ( & call , & ctx ). await ;
assert! (
! result . content . to_lowercase (). contains ( "confirm" ),
"read tool {} unexpectedly raised something confirmation-shaped: {}" ,
tool . name ,
result . content
);
}
}
/// S-13 / D-05: the loop is bounded two ways — `MAX_TURNS` overall,
/// and an early abort when the same tool name fails validation 3
/// times in a row (rather than burning the whole `MAX_TURNS` budget on
/// a model that keeps sending malformed args).
#[tokio::test]
async fn loop_is_bounded () {
let ( handler , _tmp ) = test_rpc_handler (). await ;
grant_all ( & handler ). await ;
// MAX_TURNS: a backend that always proposes another tool call must
// not run forever.
let ctx = local_operator_ctx ( handler . clone ());
let good_call = ToolCall {
id : "1" . to_string (),
name : "system_disk_status" . to_string (),
arguments : json ! ({}),
};
let turns : Vec < BackendTurn > = ( 0 .. MAX_TURNS + 2 )
. map ( | _ | BackendTurn ::ToolCalls ( vec! [ good_call . clone ()]))
. collect ();
let backend = ScriptedBackend ::new ( turns );
let tools_list = vec! [ system_disk_status_tool ()];
let result = run_loop ( & backend , "sys" , & tools_list , vec! [], & ctx ). await ;
assert! (
result . is_err (),
"run_loop must stop after MAX_TURNS rather than looping forever"
);
// 3 consecutive malformed calls for the SAME tool name abort the
// turn with an apology, before a 4th (correctly-shaped) scripted
// turn is ever reached.
let ctx2 = local_operator_ctx ( handler );
let bad_call = ToolCall {
id : "x" . to_string (),
name : "app_logs" . to_string (),
arguments : json ! ({ "not_app_id" : 1 }),
};
let turns2 = vec! [
BackendTurn ::ToolCalls ( vec! [ bad_call . clone ()]),
BackendTurn ::ToolCalls ( vec! [ bad_call . clone ()]),
BackendTurn ::ToolCalls ( vec! [ bad_call . clone ()]),
BackendTurn ::Text ( "should never be reached" . to_string ()),
];
let backend2 = ScriptedBackend ::new ( turns2 );
let tools_list2 = vec! [ app_logs_tool ()];
let answer = run_loop ( & backend2 , "sys" , & tools_list2 , vec! [], & ctx2 )
. await
. expect ( "run_loop should abort gracefully with an apology, not error" );
assert_ne! (
answer , "should never be reached" ,
"the loop must abort before the 4th scripted turn is ever polled"
);
}
/// `every_tool_has_explicit_category_and_destructive`: Rust's type
/// system already forbids a partially-constructed `ToolDef` literal —
/// there is no `Default` impl for it, so struct-update syntax is not
/// even available as an escape hatch (the acceptance criterion's grep
/// asserts this directly against the source). This test is the
/// runtime sanity check that every hand-written constructor above
/// actually made it into the registry, so a silently-dropped tool
/// doesn't slip through unnoticed.
#[test]
fn every_tool_has_explicit_category_and_destructive () {
let reg = registry ();
let all = reg . all ();
assert_eq! (
all . len (),
13 ,
"expected exactly 13 hand-written tools in the curated registry"
);
let destructive_count = all . iter (). filter ( | t | t . destructive ). count ();
assert_eq! (
destructive_count , 4 ,
"expected exactly 4 destructive tools: app_start, app_stop, app_restart, settings_set"
);
}
2026-08-03 14:37:16 -04:00
}