feat(13-05): expand curated tool registry to full D-06/D-09 allowlist, wire D-16 default-closed grants

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>
This commit is contained in:
archipelago
2026-08-04 01:28:29 -04:00
co-authored by Claude Opus 5
parent 61ebce0598
commit 90706fe053
5 changed files with 1451 additions and 116 deletions
+106 -10
View File
@@ -25,10 +25,75 @@ impl RpcHandler {
) -> Result<serde_json::Value> {
match method {
"assistant.chat" => self.handle_assistant_chat(params, session_token).await,
"assistant.list-tools" => self.handle_assistant_list_tools().await,
"assistant.grants-get" => self.handle_assistant_grants_get().await,
"assistant.grants-set" => self.handle_assistant_grants_set(params).await,
other => anyhow::bail!("no such assistant method: {other}"),
}
}
/// assistant.list-tools — the tools currently visible to the local
/// operator (i.e. whose category is currently granted), each with its
/// category and destructive flag, so neode-ui can render an honest
/// capability list rather than guessing from the grants alone.
async fn handle_assistant_list_tools(self: &Arc<Self>) -> Result<serde_json::Value> {
let grants = crate::assistant::grants::Grants::load(self.data_dir()).await;
let registry = crate::assistant::tools::registry();
let visible = registry.visible_to(grants.categories());
let tools: Vec<serde_json::Value> = visible
.iter()
.map(|t| {
serde_json::json!({
"name": t.name,
"description": t.description,
"category": t.category,
"destructive": t.destructive,
})
})
.collect();
Ok(serde_json::json!({ "tools": tools }))
}
/// assistant.grants-get — the current open/closed state of all ten
/// D-16 permission categories, for the browser's AI-permissions UI to
/// render honestly (including the ones still closed).
async fn handle_assistant_grants_get(self: &Arc<Self>) -> Result<serde_json::Value> {
let grants = crate::assistant::grants::Grants::load(self.data_dir()).await;
Ok(serde_json::json!({ "categories": grants_categories_json(&grants) }))
}
/// assistant.grants-set — open or close one permission category.
/// Params: `{ "category": string, "granted": bool }`, where `category`
/// is one of the ten kebab-case ids also used by
/// `neode-ui/src/stores/aiPermissions.ts` (`"apps"`, `"ai-local"`,
/// etc). Persists immediately via `Grants::save` — the next
/// `assistant.chat` turn (not only the next session) sees the change,
/// per `grant_revocation_takes_effect_next_turn`.
async fn handle_assistant_grants_set(
self: &Arc<Self>,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let category_str = params
.get("category")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing category"))?;
let granted = params
.get("granted")
.and_then(|v| v.as_bool())
.ok_or_else(|| anyhow::anyhow!("Missing granted"))?;
let category: crate::assistant::PermissionCategory =
serde_json::from_value(serde_json::Value::String(category_str.to_string()))
.map_err(|_| anyhow::anyhow!("Unknown permission category: {category_str}"))?;
let mut grants = crate::assistant::grants::Grants::load(self.data_dir()).await;
grants.set(category, granted);
grants.save(self.data_dir()).await?;
Ok(serde_json::json!({ "categories": grants_categories_json(&grants) }))
}
/// assistant.chat — a single chat turn from the authenticated local
/// operator. Params: `{ "text": string }`. Returns `{ "text": string }`.
async fn handle_assistant_chat(
@@ -56,18 +121,37 @@ impl RpcHandler {
/// Internal-only bridge: executes a curated assistant tool against the
/// SAME `RpcHandler` method every authenticated RPC caller dispatches
/// through (never an AI-only backdoor). NOT itself an RPC method — only
/// `assistant::loop_::execute_tool` calls this, and only for tool names
/// present in the curated D-06 registry.
///
/// Rust module privacy is what requires this thin bridge:
/// `handle_system_disk_status` is `pub(in crate::api::rpc)`, so
/// `crate::assistant` (outside that module subtree) cannot call it
/// directly. This function lives inside `api::rpc` so it CAN call the
/// private handler, and re-exposes only the one curated method name a
/// tool call is allowed to reach — not the general RPC surface.
pub(crate) async fn assistant_dispatch_tool(&self, method: &str) -> Result<serde_json::Value> {
/// `assistant::tools::dispatch` calls this, and only for the RPC method
/// names its own hand-written match arms name explicitly (D-06 — this
/// list is exactly as curated as the tool registry itself; it is not a
/// general-purpose relay onto `api::rpc`'s method table).
pub(crate) async fn assistant_dispatch_tool(
&self,
method: &str,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
match method {
"system.disk-status" => self.handle_system_disk_status().await,
"system.stats" => self.handle_system_stats().await,
"container-list" => self.handle_container_list().await,
"container-logs" => self.handle_container_logs(params).await,
"container-start" => self.handle_container_start(params).await,
"container-stop" => self.handle_container_stop(params).await,
"container-restart" => self.handle_container_restart(params).await,
"bitcoin.getinfo" => self.handle_bitcoin_getinfo().await,
"network.get-visibility" => self.handle_network_get_visibility().await,
"network.diagnostics" => self.handle_network_diagnostics().await,
"network.set-visibility" => self.handle_network_set_visibility(params).await,
"network.set-wifi-radio" => self.handle_network_set_wifi_radio(params).await,
"mesh.status" => self.handle_mesh_status().await,
"content.list-mine" => self.handle_content_list_mine().await,
"system.settings.get" => self.handle_system_settings_get(params).await,
"system.settings.set" => self.handle_system_settings_set(params).await,
"system.kiosk-display.get" => self.handle_system_kiosk_display_get().await,
"system.kiosk-display.set" => self.handle_system_kiosk_display_set(params).await,
"bitcoin.relay-update-settings" => {
self.handle_bitcoin_relay_update_settings(params).await
}
other => anyhow::bail!("assistant_dispatch_tool: no such handler for {other}"),
}
}
@@ -79,3 +163,15 @@ impl RpcHandler {
&self.config.data_dir
}
}
/// Shared shape for `assistant.grants-get` and `assistant.grants-set`'s
/// response: all ten categories, each with its current `granted` state —
/// never only the open ones, so the browser can render an honest
/// still-closed list too.
fn grants_categories_json(grants: &crate::assistant::grants::Grants) -> serde_json::Value {
let categories: Vec<serde_json::Value> = crate::assistant::PermissionCategory::ALL
.iter()
.map(|c| serde_json::json!({ "category": c, "granted": grants.allows(*c) }))
.collect();
serde_json::json!(categories)
}