Files
archy/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-05-PLAN.md
T

26 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
13-aiui-functional-conversational-node-control-and-content-surf 05 execute 2
13-01
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
true
AIUI-01
AIUI-02
truths artifacts key_links
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)
path provides contains
core/archipelago/src/assistant/tools.rs The full D-06 curated allowlist with per-tool JSON Schema, category and destructive flag fn registry()
path provides contains
core/archipelago/src/assistant/grants.rs D-16 default-closed category grants, persisted under data_dir default_closed
from to via pattern
core/archipelago/src/assistant/tools.rs core/archipelago/src/api/rpc/dispatcher.rs each ToolDef's execute dispatches to an existing authenticated RPC handler — never a parallel AI-only path handle_(container|system|bitcoin|network|content)_
from to via pattern
core/archipelago/src/assistant/grants.rs core/archipelago/src/assistant/mod.rs CallerScope::granted_categories reads the persisted grants store instead of 13-01's hardcoded default 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_assumptions> 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. </flagged_assumptions>

<artifacts_this_phase_produces> 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) </artifacts_this_phase_produces>

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.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 <behavior> bullet, using the tracer's ScriptedBackend. cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago assistant::tools:: 2>&1 | tail -20 <acceptance_criteria>

  • 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 </acceptance_criteria> 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 <behavior> 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 <acceptance_criteria>

  • 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 </acceptance_criteria> 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 <acceptance_criteria>

  • 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 </acceptance_criteria> The D-09 ceiling is a passing test over the whole registry, and it demonstrably goes red when a tool crosses it.

<threat_model>

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
</threat_model>
- `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

<success_criteria> "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. </success_criteria>

Create `.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-05-SUMMARY.md` when done