--- phase: 13-aiui-functional-conversational-node-control-and-content-surf plan: 05 type: execute wave: 2 depends_on: ["13-01"] files_modified: - core/archipelago/src/assistant/tools.rs - core/archipelago/src/assistant/grants.rs - core/archipelago/src/assistant/mod.rs - core/archipelago/src/api/rpc/assistant_chat.rs autonomous: true requirements: [AIUI-01, AIUI-02] must_haves: truths: - "An operator can change system settings by conversation, and only within the permission categories they granted (AIUI-02, D-09, D-16)" - "No ToolDef exists anywhere in the registry whose effect touches keys, seeds, wallet spends, federation trust or factory reset — the D-09 ceiling is the absence of a tool, not a runtime filter (S-04)" - "A fresh node grants nothing: all ten permission categories are closed until the operator opens them (D-16, S-06)" - "An ungranted category is refused at execute_tool even when the tool was somehow proposed — the system prompt omitting it is defense in depth, not the gate (S-05)" - "A read tool never raises a confirmation dialog (S-07)" - "The model never sees a tool it cannot use: the system prompt lists only currently-granted-category tools (D-16)" artifacts: - path: "core/archipelago/src/assistant/tools.rs" provides: "The full D-06 curated allowlist with per-tool JSON Schema, category and destructive flag" contains: "fn registry()" - path: "core/archipelago/src/assistant/grants.rs" provides: "D-16 default-closed category grants, persisted under data_dir" contains: "default_closed" key_links: - from: "core/archipelago/src/assistant/tools.rs" to: "core/archipelago/src/api/rpc/dispatcher.rs" via: "each ToolDef's execute dispatches to an existing authenticated RPC handler — never a parallel AI-only path" pattern: "handle_(container|system|bitcoin|network|content)_" - from: "core/archipelago/src/assistant/grants.rs" to: "core/archipelago/src/assistant/mod.rs" via: "CallerScope::granted_categories reads the persisted grants store instead of 13-01's hardcoded default" pattern: "granted_categories" --- Expand the tracer's one-tool registry into the full curated allowlist, and make D-09's authority ceiling and D-16's default-closed grants real and asserted. D-06 is explicit: tools are **hand-written**, never auto-generated from `dispatcher.rs`. That is the only way "the model never sees the full RPC surface" stays true rather than becoming an implementation detail nobody re-checks. D-09's ceiling — reads within granted categories, app lifecycle (start/stop/restart), settings writes; keys, seeds, wallet spends, federation trust and factory reset permanently excluded — is enforced by **not writing those ToolDefs**, and by a test that asserts over the whole registry so adding an out-of-bounds tool later fails CI rather than review. This plan also delivers AIUI-02. A finding worth stating plainly: `system.settings.set` today accepts exactly one key, `claude_api_key` (verified, `api/rpc/system/handlers.rs:1026-1071`) — and that key is *excluded* from chat reach by D-09. So conversational settings are built from a hand-picked allowlist of setting keys drawn from the surfaces that actually exist (`network.set-visibility`, `system.kiosk-display.set`, `network.set-wifi-radio`, `bitcoin.relay-update-settings`), with `claude_api_key` explicitly and permanently absent. Purpose: make the sandbox claim checkable. After this plan, "what can the chat reach" is a grep over one file and a passing test, not an argument. Output: the curated registry, the grants store, and `assistant.list-tools` / `assistant.grants-get` / `assistant.grants-set`. **FLAGGED — unresolved edge probe, AIUI-02, category `unclassified`.** The deterministic edge probe returned `unclassified — review manually` for AIUI-02 and it is NOT auto-resolved and NOT auto-backstopped. Surfaced here for a human read during execution: the requirement text ("system settings reachable by conversation, scoped to what the user granted") does not say what happens when the operator asks to change a setting that *exists in neode-ui* but is deliberately absent from the tool allowlist — refuse plainly, refuse and name the UI path, or silently omit. Task 1 chooses "refuse plainly **and** name the real UI path", which is the E-03 rubric's PASS behaviour, but the requirement itself does not mandate it. Raise it if that reading is wrong. Symbols created by **this plan**: - `core/archipelago/src/assistant/tools.rs`: `fn registry()` expanded; per-tool constructors `apps_list_tool`, `app_logs_tool`, `app_start_tool`, `app_stop_tool`, `app_restart_tool`, `bitcoin_status_tool`, `network_status_tool`, `mesh_status_tool`, `content_list_tool`, `settings_get_tool`, `settings_set_tool`; their args structs `AppIdArgs`, `AppLogsArgs`, `SettingsGetArgs`, `SettingsSetArgs`; `const SETTABLE_KEYS`, `const EXCLUDED_AUTHORITY_TERMS` - `core/archipelago/src/assistant/grants.rs`: `pub struct Grants`, `pub fn default_closed`, `Grants::load`, `Grants::save`, `Grants::allows`, `Grants::set` - `core/archipelago/src/api/rpc/assistant_chat.rs`: `handle_assistant_list_tools`, `handle_assistant_grants_get`, `handle_assistant_grants_set` - New RPC method names: `assistant.list-tools`, `assistant.grants-get`, `assistant.grants-set` (all routed through 13-01's single `assistant.` dispatcher arm — `dispatcher.rs` is not touched again) @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.planning/PROJECT.md @.planning/STATE.md @CLAUDE.md @.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-CONTEXT.md @.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-AI-SPEC.md @.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-01-SUMMARY.md Task 1: The curated allowlist — every tool a decision someone made core/archipelago/src/assistant/tools.rs - `registry()` returns exactly the hand-written tools listed in the action below, and no others. - Each tool's `parameters` is a JSON Schema object whose `required` keys all deserialize into its args struct — schema and deserialization target cannot drift. - `settings_set` refuses any key not in `SETTABLE_KEYS`, with an error naming which keys are settable. - `settings_set` refuses `claude_api_key` specifically, and the refusal names the neode-ui Settings path as the real way to do it. - `app_restart` refuses an `app_id` that is not an exact installed app id — no fuzzy match, no nearest-neighbour. - Every tool whose effect changes node state has `destructive: true`; every read tool has `destructive: false`. - `core/archipelago/src/assistant/tools.rs` — the tracer's `ToolDef`, `ToolRegistry`, `ToolDef::validate` and the single `system_disk_status` tool. **Extend this file's existing conventions; do not restructure them.** - `core/archipelago/src/api/rpc/dispatcher.rs` lines 42-50, 107-119, 205-241, 394, 465-477 — the verified handler names each tool dispatches to: `container-list`, `container-start`, `container-stop`, `container-restart`, `container-logs`, `bitcoin.getinfo`, `network.get-visibility`, `network.diagnostics`, `mesh.status`, `content.list-mine`, `system.disk-status`, `system.stats`, `system.settings.get`, `system.settings.set`, `system.kiosk-display.get`, `system.kiosk-display.set`, `network.set-visibility`, `network.set-wifi-radio`, `bitcoin.relay-update-settings`. - `core/archipelago/src/api/rpc/system/handlers.rs` lines 994-1072 — `handle_system_settings_get`/`_set`. **Confirm for yourself that `_set`'s `match key` accepts only `claude_api_key` today**; that fact drives the `SETTABLE_KEYS` design below. - `.planning/phases/13-.../13-CONTEXT.md` D-06, D-07, D-09, D-16. - `.planning/phases/13-.../13-AI-SPEC.md` §4 "Tool Use" and §4b.1 (validate-then-refuse, never coerce, ≤ 2 consecutive validation failures per tool name). Expand `registry()` to the curated allowlist. Every entry is hand-written with its own description, its own JSON Schema literal built with `serde_json::json!`, its own `PermissionCategory`, and its own `destructive` flag. **Do not derive anything from `dispatcher.rs`'s method table** — D-06 rejects that outright, and it is the single change that would make the sandbox claim untrue. Read tools (`destructive: false`): `system_disk_status` (System, already exists), `system_stats` (System), `apps_list` (Apps → `container-list`), `app_logs` (Apps → `container-logs`, args `app_id` + `lines` capped at 200), `bitcoin_status` (Bitcoin → `bitcoin.getinfo`), `network_status` (Network → `network.get-visibility` + `network.diagnostics`), `mesh_status` (Network → `mesh.status`), `content_list` (Media → `content.list-mine`), `settings_get` (System → `system.settings.get`, `network.get-visibility`, `system.kiosk-display.get` behind a hand-picked key allowlist). Write tools (`destructive: true`): `app_start`, `app_stop`, `app_restart` (Apps → `container-start`/`-stop`/`-restart`) and `settings_set` (System → the setting-specific handler for the requested key). D-09's ceiling is enforced by **absence**: there is no `wallet_send`, no `seed_reveal`, no `federation_trust`, no `factory_reset`, no `system_reboot`, no `container_install`, no `container_remove` ToolDef, and none may be added. Record the excluded set as `const EXCLUDED_AUTHORITY_TERMS: &[&str]` so Task 3's registry-wide assertion has something concrete to assert over. `settings_set` is the AIUI-02 surface and needs care. Define `const SETTABLE_KEYS: &[&str]` containing only setting keys that (a) have a real handler today and (b) are not key material: network visibility, kiosk display preset, wifi radio on/off, and the bitcoin relay settings. `claude_api_key` is **excluded** — it is key material, D-09 puts keys permanently outside chat reach, and the fact that it is the *only* key `system.settings.set` accepts today is not a reason to include it. On a request for an unlisted key, return an `is_error: true` ToolResult whose text names the settable keys and points at the neode-ui Settings screen as the real path (this is the E-03 PASS behaviour: refuse plainly, do not fabricate, redirect to the real UI). `app_start`/`app_stop`/`app_restart` take an exact installed `app_id`. Their descriptions must state that the id is exact and never fuzzy-matched, and include one inline example call (AI-SPEC §4b.3: few-shot inline, not retrieved). Validation resolves the id against `container-list` and refuses an unknown id with an error listing installed ids — EV-08's "restart the node" case must ask which app rather than guessing. Per AI-SPEC §4b.1: `validate` deserializes and refuses; never coerce, never guess, never panic. Add the ≤ 2-consecutive-validation-failures-per-tool-name counter to the ToolExecCtx so a model looping on malformed args aborts the turn with an apology rather than spinning. Write the tests FIRST, one per `` bullet, using the tracer's `ScriptedBackend`. cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago assistant::tools:: 2>&1 | tail -20 - `cd core && cargo test --package archipelago assistant::tools::` exits 0 - `grep -q 'SETTABLE_KEYS' core/archipelago/src/assistant/tools.rs` and `grep -q 'EXCLUDED_AUTHORITY_TERMS' core/archipelago/src/assistant/tools.rs` - `grep -vE '^\s*//' core/archipelago/src/assistant/tools.rs | grep -ciE 'wallet_send|seed_reveal|factory_reset|system_reboot|container_install|container_remove'` returns 0 — the excluded authority has no ToolDef in non-comment source - `grep -vE '^\s*//' core/archipelago/src/assistant/tools.rs | grep -c '"claude_api_key"'` returns 0 outside the `SETTABLE_KEYS` refusal message path — verify by reading, then assert `grep -c 'SETTABLE_KEYS' core/archipelago/src/assistant/tools.rs` ≥ 1 and that `claude_api_key` is not one of its elements - `grep -c 'destructive: true' core/archipelago/src/assistant/tools.rs` returns 4 — `app_start`, `app_stop`, `app_restart`, `settings_set` and nothing else - Every `ToolDef` literal in the file has an explicit `category:` and `destructive:` field (no `..Default::default()`) - `grep -ci 'dispatcher' core/archipelago/src/assistant/tools.rs` returns 0 — nothing is generated from the method table D-09's first-cut authority is rated costly in CONTEXT.md: widening later is safe, but any capability shipped and then withdrawn breaks a behaviour users will have learned. Flagged, not gated — the ceiling here is deliberately conservative. The registry is a readable list of hand-written decisions; 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. Task 2: Default-closed grants, and a system prompt that only shows what is granted core/archipelago/src/assistant/grants.rs, core/archipelago/src/assistant/mod.rs, core/archipelago/src/api/rpc/assistant_chat.rs - A fresh node with no grants file returns an empty granted set for every caller variant. - `assistant.grants-set` opens a named category; `assistant.grants-get` reflects it; the change survives a daemon restart. - The system prompt built for a caller lists only tools whose category is currently granted — an ungranted tool's name does not appear in the prompt string at all. - `assistant.list-tools` returns only granted-category tools, with each tool's category and destructive flag, so neode-ui can render an honest capability list. - `execute_tool` still refuses an ungranted category even when the tool was proposed anyway — the prompt filter is defense in depth, not the gate. - Revoking a category takes effect on the next turn, not only on the next session. - `core/archipelago/src/assistant/mod.rs` — the tracer's `CallerScope::granted_categories`, which currently returns a hardcoded `{System}` for `LocalOperator`. **This task replaces the source of that set, not its shape** — the `` promote in 13-01 is what makes that a data change rather than an architectural one. - `neode-ui/src/stores/aiPermissions.ts` — the ten user-toggled categories and their labels, already shipped in the browser. The node-side names must match these exactly or the two consent surfaces will disagree. - `core/archipelago/src/streaming/session.rs` — `13-PATTERNS.md`'s role-match analog for `data_dir`-scoped persisted state. Follow its load/save/permissions convention. - `core/archipelago/src/api/rpc/mesh/assistant.rs` — the handler shape for the three new `assistant.*` methods, and `trusted_only`/`allowed_contacts`/`denied_askers`, which stay the resolution inputs for the `Mesh` variant. - `.planning/phases/13-.../13-AI-SPEC.md` §4b.3 "Prompt Engineering Discipline" — one static, phase-authored system prompt, never assembled from prior model output, listing only granted-category tools and stating the confirm-gate contract explicitly. Create `core/archipelago/src/assistant/grants.rs` with `pub struct Grants(BTreeSet)`, `pub fn default_closed() -> Grants` returning an empty set, and `load`/`save` against a JSON file under `data_dir` (0600, following `streaming/session.rs`'s convention). A missing file is `default_closed()`, never an error and never a permissive default — D-16 accepts that the assistant looks unconfigured on a fresh node. Rewire `CallerScope::granted_categories` in `mod.rs`: `LocalOperator` reads the persisted `Grants`; `Mesh` resolves from the existing `trusted_only`/`allowed_contacts`/`denied_askers` inputs intersected with the persisted `Grants`, so a mesh peer can never exceed what the operator opened. Both variants resolve through the same method — that is the promoted-primary contract from 13-01, and the suggested invariant test `every_caller_variant_resolves_authority_through_caller_scope` belongs here now that there are two real sources. Add the system-prompt builder to `mod.rs`: one static, phase-authored string that states the operator-control persona, appends **only** the granted-category tools' names and descriptions, and states the confirm-gate contract verbatim — that every write requires a human confirmation the model cannot bypass or pre-approve on the user's behalf. It is never assembled from prior model output and never editable by AIUI. Add `handle_assistant_list_tools`, `handle_assistant_grants_get` and `handle_assistant_grants_set` to `assistant_chat.rs`, routed through 13-01's existing `assistant.` prefix arm. **Do not touch `dispatcher.rs`** — that is the whole point of the prefix arm, and it keeps this plan's `files_modified` free of a file three other plans also want. Write the tests FIRST, one per `` bullet. Name them `assistant::tests::fresh_node_grants_are_empty` (S-06), `assistant::tools::tests::settings_tool_respects_category_grant` (S-05), `assistant::tests::ungranted_tool_absent_from_system_prompt`, `assistant::tests::grant_revocation_takes_effect_next_turn`, `assistant::tests::every_caller_variant_resolves_authority_through_caller_scope`. cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago assistant:: 2>&1 | tail -25 cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago fresh_node_grants_are_empty cd core && git diff --exit-code -- archipelago/src/api/rpc/dispatcher.rs - `grep -q 'pub fn default_closed' core/archipelago/src/assistant/grants.rs` and the function body returns an empty set - `cd core && cargo test --package archipelago assistant::` exits 0 with `fresh_node_grants_are_empty`, `settings_tool_respects_category_grant`, `ungranted_tool_absent_from_system_prompt`, `grant_revocation_takes_effect_next_turn` and `every_caller_variant_resolves_authority_through_caller_scope` all passing - `cd core && git diff --exit-code -- archipelago/src/api/rpc/dispatcher.rs` exits 0 — the prefix arm absorbed all three new methods - The ten `PermissionCategory` variant names in `mod.rs` match the ten category ids in `neode-ui/src/stores/aiPermissions.ts` one-for-one (diff the two lists by hand and record the result in the summary) - `grep -c '0o600\|from_mode' core/archipelago/src/assistant/grants.rs` ≥ 1 — the grants file is not world-readable A fresh node's assistant can do nothing until a category is opened; opening one survives a restart; and an ungranted tool is invisible to the model *and* refused at the gate. Task 3: Assert the ceiling over the whole registry, so a future tool fails CI not review core/archipelago/src/assistant/tools.rs - `core/archipelago/src/assistant/tools.rs` — the registry and `EXCLUDED_AUTHORITY_TERMS` from Task 1. - `.planning/phases/13-.../13-AI-SPEC.md` §5 structural invariants **S-04** and **S-07**, and §1b's "Regulatory / Compliance Context" — D-09's exclusion of wallet spends/keys/seeds is what keeps the software inside the MiCA/GENIUS non-custodial carve-out, so this is a regulatory-adjacent invariant, not only a security one. - `.planning/phases/13-.../13-AI-SPEC.md` §6 guardrail **G-S5** — "the D-09 ceiling is the absence of tools". Add the registry-wide structural assertions to `tools.rs`'s test module. These iterate the **whole registry** rather than checking named tools, so a tool added in a later phase that crosses the ceiling fails CI rather than depending on a reviewer noticing. `registry_never_exposes_excluded_authority` (S-04): for every `ToolDef` in `registry()`, assert that neither its `name` nor its `description` contains any term in `EXCLUDED_AUTHORITY_TERMS` (seed, mnemonic, private key, macaroon, spend, send sats, pay invoice, federation trust, factory reset, wipe), and that no tool's category is one this phase does not use for writes. Include a comment naming §1b's regulatory rationale so a future maintainer relaxing this assertion knows what they are relaxing. `read_tools_never_confirm` (S-07): for every `ToolDef` with `destructive: false`, run a scripted turn that calls it and assert **zero** confirmation requests were raised. Habituation is a real failure mode here — every unnecessary dialog spends the confirm gate's signal value (AI-SPEC §1b, Bravo-Lillo et al.), so this is a consent property, not a tidiness one. `loop_is_bounded` (S-13): assert `MAX_TURNS` is enforced and that a model emitting malformed args for the same tool three times in a row aborts the turn rather than continuing. `every_tool_has_explicit_category_and_destructive`: assert by construction that no `ToolDef` in the registry was built with a defaulted field — a tool that silently defaults to `destructive: false` is the exact bug this whole gate exists to prevent. cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago assistant::tools::tests:: 2>&1 | tail -20 cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago registry_never_exposes_excluded_authority - `cd core && cargo test --package archipelago assistant::tools::tests::` exits 0 with `registry_never_exposes_excluded_authority`, `read_tools_never_confirm`, `loop_is_bounded` and `every_tool_has_explicit_category_and_destructive` all passing - The S-04 test iterates `registry()` rather than a hardcoded list of tool names — confirm by reading; a test that names tools individually does not catch a tool added later - Temporarily adding a `ToolDef` named `wallet_send_sats` to `registry()` makes `registry_never_exposes_excluded_authority` fail; remove it afterwards and record the observed failure message in the summary The D-09 ceiling is a passing test over the whole registry, and it demonstrably goes red when a tool crosses it. ## Trust Boundaries | Boundary | Description | |----------|-------------| | model output → `execute_tool` | Tool name and arguments are model-chosen; both are validated before anything runs | | operator grants → tool authority | The only source of authority. Peer-supplied content is not a source (D-10, enforced in 13-12) | | tool → existing RPC handler | Tools call the same handlers every other authenticated caller uses; there is no AI-only backdoor | | `settings_set` → node configuration | The one write surface AIUI-02 opens; bounded by `SETTABLE_KEYS` | ## STRIDE Threat Register | Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | |-----------|----------|-----------|----------|-------------|-----------------| | T-13-24 | Elevation of Privilege | A tool for excluded authority (seed, spend, federation trust, factory reset) | **critical** | mitigate | G-S5: no such `ToolDef` exists. Asserted registry-wide by `registry_never_exposes_excluded_authority`, and demonstrated to go red by the negative-case criterion | | T-13-25 | Elevation of Privilege | `settings_set` reaching `claude_api_key` | high | mitigate | `SETTABLE_KEYS` excludes it; the refusal names the neode-ui Settings path. Key material is UI-only per D-09 | | T-13-26 | Elevation of Privilege | Ungranted category reached because the prompt filter was the only gate | high | mitigate | G-S6: two independent layers — prompt filtering **and** the `execute_tool` grant check. Asserted by `settings_tool_respects_category_grant` with a tool the prompt omitted | | T-13-27 | Tampering | Fuzzy-matched `app_id` restarts the wrong container | medium | mitigate | Exact-id validation against `container-list`; an unknown id lists the installed ids instead of guessing (EV-08) | | T-13-28 | Denial of Service | Model loops on malformed arguments | medium | mitigate | ≤ 2 consecutive validation failures per tool name, then abort the turn; plus `MAX_TURNS`. Asserted by `loop_is_bounded` | | T-13-29 | Information Disclosure | A permissive grants default on a fresh node | high | mitigate | `default_closed()` returns empty; a missing file is not an error and not permissive. Asserted by `fresh_node_grants_are_empty` | | T-13-30 | Spoofing | Node-side and browser-side category vocabularies drift, so consent shown ≠ consent enforced | medium | mitigate | Acceptance criterion diffs the ten `PermissionCategory` variants against `neode-ui/src/stores/aiPermissions.ts` | | T-13-31 | Repudiation | A confirmation raised for a read action trains click-through | medium | mitigate | S-07 `read_tools_never_confirm` over every non-destructive tool. Habituation research (AI-SPEC §1b) treats this as a consent failure, not a UX nit | | T-13-SC | Tampering | npm/pip/cargo installs | high | mitigate | **Zero** packages added; JSON Schema stays hand-written (`schemars` remains rejected as un-audited). No install task, so no legitimacy checkpoint required | - `cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago assistant::` green - `cd core && git diff --exit-code -- archipelago/src/api/rpc/dispatcher.rs` exits 0 - The negative case is demonstrated: adding `wallet_send_sats` to the registry turns S-04 red - The ten node-side categories match the ten browser-side categories exactly "What can the chat reach" is answerable by reading one file, and "what it can never reach" is a test that iterates the whole registry and goes red when crossed. Conversational settings work within a hand-picked key allowlist that deliberately excludes key material. Create `.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-05-SUMMARY.md` when done