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:
co-authored by
Claude Opus 5
parent
61ebce0598
commit
90706fe053
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user