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 |
|
|
true |
|
|
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 constructorsapps_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 structsAppIdArgs,AppLogsArgs,SettingsGetArgs,SettingsSetArgs;const SETTABLE_KEYS,const EXCLUDED_AUTHORITY_TERMScore/archipelago/src/assistant/grants.rs:pub struct Grants,pub fn default_closed,Grants::load,Grants::save,Grants::allows,Grants::setcore/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 singleassistant.dispatcher arm —dispatcher.rsis 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 0grep -q 'SETTABLE_KEYS' core/archipelago/src/assistant/tools.rsandgrep -q 'EXCLUDED_AUTHORITY_TERMS' core/archipelago/src/assistant/tools.rsgrep -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 sourcegrep -vE '^\s*//' core/archipelago/src/assistant/tools.rs | grep -c '"claude_api_key"'returns 0 outside theSETTABLE_KEYSrefusal message path — verify by reading, then assertgrep -c 'SETTABLE_KEYS' core/archipelago/src/assistant/tools.rs≥ 1 and thatclaude_api_keyis not one of its elementsgrep -c 'destructive: true' core/archipelago/src/assistant/tools.rsreturns 4 —app_start,app_stop,app_restart,settings_setand nothing else- Every
ToolDefliteral in the file has an explicitcategory:anddestructive:field (no..Default::default()) grep -ci 'dispatcher' core/archipelago/src/assistant/tools.rsreturns 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.
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.rsand the function body returns an empty setcd core && cargo test --package archipelago assistant::exits 0 withfresh_node_grants_are_empty,settings_tool_respects_category_grant,ungranted_tool_absent_from_system_prompt,grant_revocation_takes_effect_next_turnandevery_caller_variant_resolves_authority_through_caller_scopeall passingcd core && git diff --exit-code -- archipelago/src/api/rpc/dispatcher.rsexits 0 — the prefix arm absorbed all three new methods- The ten
PermissionCategoryvariant names inmod.rsmatch the ten category ids inneode-ui/src/stores/aiPermissions.tsone-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.
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 withregistry_never_exposes_excluded_authority,read_tools_never_confirm,loop_is_boundedandevery_tool_has_explicit_category_and_destructiveall 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
ToolDefnamedwallet_send_satstoregistry()makesregistry_never_exposes_excluded_authorityfail; 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> |
<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