Files

27 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 08 execute 3
13-05
core/archipelago/src/assistant/confirm.rs
core/archipelago/src/assistant/loop_.rs
core/archipelago/src/assistant/mod.rs
core/archipelago/src/api/rpc/assistant_chat.rs
neode-ui/src/components/ToolConfirmModal.vue
neode-ui/src/services/contextBroker.ts
neode-ui/src/views/Chat.vue
neode-ui/src/services/__tests__/toolConfirm.test.ts
false
AIUI-01
AIUI-04
truths prohibitions artifacts key_links
An operator asks for a state change and nothing happens until they approve a dialog that names the real action (D-07, D-11)
The confirmation dialog is drawn by neode-ui outside the iframe, Teleported to body with a full-screen backdrop — the iframe cannot spoof, restyle or pre-click it (D-11)
The approved action is byte-identical to the executed action: approval binds to a node-minted nonce over the tool name and validated arguments, and a mismatched or replayed nonce is refused (S-02)
Confirmation text is assembled from the node's own ToolDef description plus validated arguments — it contains zero model-supplied and zero iframe-supplied strings (S-03)
Two confirmations for different resources produce visibly different text: the resource identifier appears verbatim and differs (S-08)
Pending confirmations are in-memory only — a daemon restart mid-wait resolves as declined and never resurrects a stale write (S-09)
The confirm-gate wait never holds a shared lock: other RPC calls, including mesh.assistant-status, are unaffected while a human decides
statement status verification
A confirmation must never be raised for an action that does not change state — routine dialogs train the operator to click yes without reading, at which point the gate is present, working, and no longer consent. active unverified
path provides contains
core/archipelago/src/assistant/confirm.rs D-11 pending-confirmation queue: node-authored description, nonce binding, in-memory only pub struct PendingConfirmation
path provides contains
neode-ui/src/components/ToolConfirmModal.vue Trusted-chrome approve/deny modal, Teleport to body, RPC-fetched text Teleport
from to via pattern
core/archipelago/src/assistant/loop_.rs core/archipelago/src/assistant/confirm.rs execute_tool suspends on ctx.confirm.request(tool, &args) before (tool.execute) confirm.request
from to via pattern
neode-ui/src/components/ToolConfirmModal.vue core/archipelago/src/api/rpc/assistant_chat.rs assistant.confirm-tool over the page's authenticated RPC session, carrying the node-minted nonce assistant.confirm-tool
Build the gate that does the safety work. D-07: every write needs confirmation regardless of backend — which is what makes backend choice a privacy decision rather than a safety one, and what makes a mis-called tool from a weak local model a prompt the user rejects instead of a wrong action. D-11: the dialog renders in neode-ui's trusted chrome, outside the iframe, drawn by the host from the node's own description of the pending action — never by AIUI and never from model-authored text.

Two properties carry the whole threat model and are easy to get subtly wrong:

Confirmed-vs-executed parity. It is not enough that a confirmation happened. The action that runs must be the one the human read. Approval binds to a node-minted nonce over hash(tool_name, validated_args); a replayed or cross-action "yes" is refused arithmetically. Without this, EV-12's attack — peer content persuading the model to describe a restart as "a routine cache refresh" — degrades from "the dialog still names the real action" to a race.

Habituation. AI-SPEC §1b treats a run of near-identical dialogs as a consent failure, not a UX nit: the well-established finding is that identical-looking repeated dialogs lose their signal after roughly the second exposure, and that habituation generalizes across visually similar dialogs. So reads never confirm (already asserted in 13-05's S-07), and two confirmations in a session must be distinguishable at a glance.

The dialog is also the domain's signing screen. The hardware-wallet standard applies: name the specific resource, the concrete effect, and the blast-radius boundary — not a tool name, not raw JSON, not a bare "Are you sure?".

Output: assistant/confirm.rs, assistant.confirm-tool, and ToolConfirmModal.vue.

<flagged_assumptions> None in this plan. </flagged_assumptions>

<artifacts_this_phase_produces> Symbols created by this plan:

Rust

  • assistant/confirm.rs: pub struct ConfirmGate, pub struct PendingConfirmation, pub enum Confirmed (Yes, No, TimedOut), ConfirmGate::request, ConfirmGate::resolve, ConfirmGate::peek, fn mint_nonce, fn build_description, const CONFIRM_TIMEOUT
  • assistant/loop_.rs: the destructive branch of execute_tool filled in
  • api/rpc/assistant_chat.rs: handle_assistant_confirm_tool, handle_assistant_pending
  • New RPC method names: assistant.confirm-tool, assistant.pending (both through 13-01's existing assistant. arm — dispatcher.rs is not touched)

neode-ui

  • components/ToolConfirmModal.vue (new component)
  • services/contextBroker.ts: handleToolConfirmRequest, the aiui:tool-confirm-request / aiui:tool-confirm-response CustomEvent pair
  • views/Chat.vue: the modal mount
  • services/__tests__/toolConfirm.test.ts (new suite) </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-PATTERNS.md @.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-05-SUMMARY.md Task 1: The gate — node-authored text, nonce-bound approval, in-memory only core/archipelago/src/assistant/confirm.rs, core/archipelago/src/assistant/loop_.rs, core/archipelago/src/assistant/mod.rs, core/archipelago/src/api/rpc/assistant_chat.rs - A destructive tool call suspends the loop before execution and produces a pending confirmation; nothing runs until it is resolved. - Resolving with the correct nonce executes exactly the action the pending entry described. - Resolving with a nonce minted for a *different* pending action is refused; neither action executes. - Replaying a nonce that was already resolved is refused. - The built description contains the tool's own description text and the validated argument values, and contains no substring taken from the model's turn. - Two pending confirmations for different app ids produce descriptions that differ, and each contains its own app id verbatim. - Dropping and recreating the `ConfirmGate` (the daemon-restart analogue) leaves no pending entry; a subsequent resolve of the old nonce is refused, not executed. - A confirmation that is never resolved times out and returns a declined result — it does not execute and does not leak the waiting task. - The confirm wait holds no shared lock: a second RPC needing the same state completes while a confirmation is outstanding. - `.planning/phases/13-.../13-AI-SPEC.md` §4 (the `execute_tool` sketch — its `destructive` branch is what this task fills), §4b.2 "Async-First Design" (the lock-across-await mistake), §4 "State Management" (pending confirmations are in-memory only, keyed by `req_id`/`call_id`, never persisted), §5 invariants **S-01, S-02, S-03, S-08, S-09**, and §1b's "Confirmation clarity" rubric. - `core/archipelago/src/assistant/loop_.rs` — the tracer's `execute_tool`, whose `destructive` branch currently returns a not-yet-implemented error. - `core/archipelago/src/assistant/tools.rs` — the four `destructive: true` tools from 13-05 and their args structs; `ToolDef.description` is the source text for the dialog. - `core/archipelago/src/mesh/listener/assist.rs` — its own doc comment, "Spawned off the radio loop so it never blocks". Inherit that discipline: acquire and drop locks *around* the confirm wait, never across it. - `core/archipelago/src/api/rpc/mesh/assistant.rs` — the handler shape for the two new methods. Create `core/archipelago/src/assistant/confirm.rs` with a `ConfirmGate` holding an in-memory map from `req_id` to `PendingConfirmation { call_id, tool_name, validated_args, description, nonce, created_at, responder }`. There is no persistence path in this file and none may be added — a daemon restart must force a fresh model turn and a freshly-authored confirmation, not resurrect a stale write whose real-world preconditions may have changed.

mint_nonce computes a nonce over the tool name and the validated arguments (post-ToolDef::validate, so it binds what will actually run, not what the model sent). resolve(req_id, nonce, approved) refuses when the nonce does not match the stored pending entry or when the entry is already resolved, returning a distinct refusal that the caller logs at error level and surfaces to the owner — a nonce mismatch can only mean a replay attempt or a bug in the trusted chrome, so it is loud and sticky, not a toast.

build_description(tool, args) assembles the dialog text from ToolDef.description and the validated argument values only. The model's turn is never a source. Write it so the resource identifier — the app id, the setting key — appears verbatim in the text, because that is what makes two confirmations in a session distinguishable at a glance rather than interchangeable. Follow the clear-signing standard: name the resource, name the concrete effect, and name the boundary of what is not affected. The restart_app description should surface a timing caveat where the node knows one (for instance that a bitcoind restart pauses but does not lose initial-sync progress) — that is the confirmation doing real work, and it is a tool-description requirement rather than a new gate.

Fill execute_tool's destructive branch in loop_.rs: after validate and after the grant check, call ctx.confirm.request(tool, &args).await and branch on Confirmed::Yes to execute, Confirmed::No | Confirmed::TimedOut to return an error ToolResult saying the user declined. Read the surrounding lock guards and ensure none is held across this await — the wait is human-speed and can be minutes. CONFIRM_TIMEOUT is a new constant in this module.

Add handle_assistant_confirm_tool and handle_assistant_pending to assistant_chat.rs, routed through 13-01's existing assistant. prefix arm. assistant.pending returns the node-authored description and the nonce for the current pending action so the host chrome can fetch the text over the authenticated RPC session rather than receive it from the iframe. Do not touch dispatcher.rs.

Write the tests FIRST, one per <behavior> bullet, using 13-01's ScriptedBackend to emit a destructive tool call on demand. Name them assistant::tests::destructive_tool_requires_confirm (S-01), assistant::confirm::tests::approval_nonce_binds_to_exact_action (S-02), assistant::confirm::tests::description_contains_no_model_text (S-03), assistant::confirm::tests::distinct_resources_yield_distinct_text (S-08), assistant::confirm::tests::restart_drops_pending_not_executes (S-09), assistant::confirm::tests::timeout_declines_and_does_not_execute, assistant::confirm::tests::confirm_wait_holds_no_shared_lock. cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago assistant:: 2>&1 | tail -30 cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago approval_nonce_binds_to_exact_action cd core && git diff --exit-code -- archipelago/src/api/rpc/dispatcher.rs <acceptance_criteria>

  • cd core && cargo test --package archipelago assistant:: exits 0 with all seven named tests passing
  • grep -q 'pub struct PendingConfirmation' core/archipelago/src/assistant/confirm.rs
  • grep -ciE 'fs::write|save|persist|data_dir' core/archipelago/src/assistant/confirm.rs returns 0 — the queue has no persistence path (S-09 is structural, not a policy)
  • grep -q 'confirm.request' core/archipelago/src/assistant/loop_.rs and it appears before the execute call in execute_tool — verify by reading the branch order
  • cd core && git diff --exit-code -- archipelago/src/api/rpc/dispatcher.rs exits 0
  • Manually flip restart_app's destructive flag to false and confirm destructive_tool_requires_confirm goes red; restore it and record the observed failure in the summary </acceptance_criteria> D-11 is rated costly in CONTEXT.md: this is the load-bearing anti-spoofing property, and moving the dialog inside the iframe later would invalidate the threat model, not just the styling. Flagged, not gated. A destructive tool suspends the loop; only the matching nonce executes it; a mismatched or replayed nonce is refused; the dialog text is node-authored and resource-distinct; and a restart drops the pending action rather than running it.
Task 2: The trusted chrome — a modal the iframe cannot reach neode-ui/src/components/ToolConfirmModal.vue, neode-ui/src/services/contextBroker.ts, neode-ui/src/views/Chat.vue, neode-ui/src/services/__tests__/toolConfirm.test.ts - When the node reports a pending confirmation, the modal opens with the node-fetched description text. - Approving calls `assistant.confirm-tool` over the page's own RPC session with the node-minted nonce; the iframe is not in that path. - Denying calls the same method with `approved: false`; the modal closes and the chat reports the decline. - A message from the iframe that looks like a confirmation payload does not open the modal and does not resolve an open one. - The modal renders as a direct child of `document.body` with a full-screen backdrop, so no ancestor transform can trap it. - Two confirmations in sequence render their two different descriptions; the second does not reuse the first's text. - Closing the modal without a decision leaves the action pending until the node's own timeout, rather than silently approving. - `neode-ui/src/components/NostrSignConsent.vue` (full file, ~70 lines) — `13-PATTERNS.md`'s **exact-match** analog: the project's canonical Teleport-to-body approve/deny modal. Copy its structure, its backdrop, its z-index and its button treatment. - `neode-ui/src/services/contextBroker.ts` lines 140-196 — the existing `install-app` confirm flow (`aiui:install-request` / `aiui:install-response`, 60s timeout). This is the shape to extend, but **with a new, distinct event pair** — `aiui:install-request` is install-specific and must not be reused (13-PATTERNS.md and RESEARCH both say so). - `neode-ui/src/views/Chat.vue` lines 25-55 — the iframe element and its surrounding template, where the modal is mounted as a sibling. - `CLAUDE.md`'s repeatedly-reinforced rule: modals Teleport to body for a full-screen backdrop; a `glass-panel` transform traps `position: fixed`. - `neode-ui/src/services/__tests__/contextBroker.test.ts` — the suite conventions the new `toolConfirm.test.ts` follows. Create `neode-ui/src/components/ToolConfirmModal.vue` modelled directly on `NostrSignConsent.vue`: `Teleport to="body"`, a `Transition`, a fixed full-screen container, an absolutely-positioned backdrop, and a `glass-card` panel with Deny and Approve buttons. Mount it in `Chat.vue` as a sibling of the iframe, never inside it.

The component's text comes in as a prop and originates only from assistant.pending's RPC response. It must not render its body text as raw markup — use plain interpolation so peer-influenced argument values cannot inject markup — and it must not read anything from the iframe's message channel. There is no code path in this component that accepts a description from the frame.

In contextBroker.ts add handleToolConfirmRequest: when a chat turn reports a pending confirmation, fetch the description and nonce with rpcClient.call({ method: 'assistant.pending' }), dispatch a CustomEvent('aiui:tool-confirm-request') carrying only the node-fetched values, and listen for aiui:tool-confirm-response — a new, distinct event pair, not the install-app one. On response, call rpcClient.call({ method: 'assistant.confirm-tool', params: { req_id, nonce, approved } }). The user's decision travels over the authenticated RPC channel, not back through the frame, so the iframe cannot forge it.

Add a guard so an inbound frame message whose type resembles a confirmation is ignored: the switch has no arm for it, and the new listener is on window for the host's own CustomEvent, not on the frame's channel. Add an explicit test for this.

Write toolConfirm.test.ts FIRST, one test per <behavior> bullet, mocking rpcClient. Name the forgery case iframe_message_cannot_open_or_resolve_confirmation. cd neode-ui && npx vitest run src/services/tests/toolConfirm.test.ts cd neode-ui && npx vitest run src/services/tests/contextBroker.test.ts src/views/tests/chatAiuiEmbed.test.ts cd neode-ui && npx vue-tsc --noEmit <acceptance_criteria>

  • cd neode-ui && npx vitest run src/services/__tests__/toolConfirm.test.ts exits 0 with a test per <behavior> bullet, including iframe_message_cannot_open_or_resolve_confirmation
  • grep -q 'Teleport to="body"' neode-ui/src/components/ToolConfirmModal.vue
  • grep -ci 'postmessage' neode-ui/src/components/ToolConfirmModal.vue returns 0 — the component has no path from the frame's channel
  • grep -ci 'v-html' neode-ui/src/components/ToolConfirmModal.vue returns 0
  • grep -c 'aiui:install-request' neode-ui/src/components/ToolConfirmModal.vue returns 0 and grep -c 'aiui:tool-confirm-request' neode-ui/src/services/contextBroker.ts returns ≥ 1 — a distinct event pair, not the install one
  • grep -q 'assistant.pending' neode-ui/src/services/contextBroker.ts — the text is RPC-fetched
  • grep -q 'ToolConfirmModal' neode-ui/src/views/Chat.vue
  • cd neode-ui && npx vitest run src/services/__tests__/contextBroker.test.ts src/views/__tests__/chatAiuiEmbed.test.ts exits 0 (pre-existing suites still green)
  • cd neode-ui && npx vue-tsc --noEmit exits 0 </acceptance_criteria> The confirmation renders in host chrome outside the iframe with a full-screen backdrop, its text comes from the node over RPC, and no message from the frame can open or resolve it.
Task 3: Look at the dialog — anti-spoofing and clear-signing are things you see The full write path: ask the embedded AIUI to restart an app; the node suspends the loop, authors a description, and neode-ui draws it outside the iframe; approving executes exactly that action, denying executes nothing.

This is a checkpoint because the two properties that matter here are not cargo test-shaped. Whether the dialog is genuinely outside the iframe and un-restylable by it is a visual/trust property. And whether the copy clears the clear-signing bar — a non-technical owner can state which resource is affected and what the consequence is — is a judgement, and AI-SPEC §1b is explicit that a security-minded reviewer systematically under-catches confusing copy because they already understand the domain.

  1. Build and deploy to archi-dev-box per CLAUDE.md (dev pair before any OTA). Build the frontend with cd neode-ui && npm run build and grep the built bundle for a string from ToolConfirmModal.vue before shipping — the build can silently no-op. Then verify node-side by resolving the live chunk via sw.js and fetching it over HTTP, not by grepping the node's assets/ directory (it is a never-pruned graveyard and will report "deployed" before the deploy).
  2. Open neode-ui's Chat view, grant the apps category, and type a request to restart a specific installed app.
  3. Observe the dialog. Confirm: it covers the whole viewport including the area over the iframe; the backdrop is full-screen (not clipped to the chat panel); the app id appears verbatim in the text; the text names a concrete effect and says what is not affected.
  4. Read the dialog as if you did not write it. Can a non-technical owner state what will happen? If it shows a tool name or raw JSON, that is the blind-signing failure and it fails.
  5. Deny. Confirm nothing happened to the container and the chat reports the decline honestly rather than claiming it restarted.
  6. Ask again and approve. Confirm the container actually restarted and the chat reports it.
  7. Ask for a second, different app. Confirm the two dialogs read differently at a glance.
  8. Ask a read-only question ("how much space is left"). Confirm no dialog appears.
  9. Trigger a confirmation, then restart the archipelago service while it is open. Confirm the action does not execute on restart. <acceptance_criteria>
  • grep -q "<a string from ToolConfirmModal.vue>" web/dist/neode-ui/assets/*.js before deploy
  • The dialog's backdrop covers the full viewport including over the iframe (screenshot recorded in the summary)
  • The dialog text contains the exact app id, a stated effect, and a stated non-effect; it contains no tool name and no JSON
  • Denying leaves podman ps output for that container unchanged, and the chat says it was declined
  • Approving restarts that container and only that container
  • Two different apps produce two visibly different dialogs
  • A read-only question produces zero dialogs
  • Restarting archipelago.service with a confirmation open results in no execution </acceptance_criteria> Type "approved" and paste the exact dialog text you saw, or describe what read wrong.

<threat_model>

Trust Boundaries

Boundary Description
model turn → confirmation text Never crosses. The dialog is assembled node-side from ToolDef.description + validated args
iframe → confirmation dialog The dialog renders in host chrome; the frame has no path to open, restyle or resolve it
user decision → node Travels over the authenticated RPC session carrying a node-minted nonce, not back through the frame
pending state → disk Never crosses. In-memory only, by construction

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-13-46 Spoofing Model-authored or iframe-authored text presented as a system confirmation critical mitigate G-S3: build_description reads only ToolDef.description + validated args. Asserted by description_contains_no_model_text; the component has no path from the frame's channel, asserted by grep and by iframe_message_cannot_open_or_resolve_confirmation
T-13-47 Tampering Confirmed action and executed action diverge (EV-12) critical mitigate G-S2: approval binds to a nonce over hash(tool_name, validated_args); a cross-action or replayed yes is refused arithmetically. Asserted by approval_nonce_binds_to_exact_action
T-13-48 Elevation of Privilege A write executes with no confirmation at all critical mitigate G-S1: the gate sits in execute_tool before (tool.execute), keyed on ToolDef.destructive, and the model's output is an input to the check rather than the check. Asserted by destructive_tool_requires_confirm and by the flip-the-flag negative case
T-13-49 Tampering A stale pending write resurrected after a restart, when its preconditions have changed high mitigate S-09: no persistence path exists in confirm.rs. Asserted structurally by the no-fs::write grep and behaviourally by restart_drops_pending_not_executes and the on-device step 9
T-13-50 Repudiation Habituation — a run of near-identical dialogs makes consent hollow high mitigate S-07 (13-05) keeps reads dialog-free; S-08 makes resources distinguishable. Recorded as this plan's prohibition. Post-ship, F-3 (median time-to-decision < 2s with a ~0 decline rate) is the rubber-stamp signature
T-13-51 Denial of Service An unresolved confirmation leaks a waiting task or stalls other RPCs medium mitigate CONFIRM_TIMEOUT declines and cleans up; no shared lock is held across the await. Asserted by timeout_declines_and_does_not_execute and confirm_wait_holds_no_shared_lock
T-13-52 Tampering Markup injected via a peer-influenced argument value rendered in the dialog medium mitigate Plain interpolation only; the raw-HTML directive is absent, asserted by grep
T-13-53 Spoofing Reusing aiui:install-request so an install confirmation and a tool confirmation become interchangeable medium mitigate A new, distinct event pair; asserted by grep on both files
T-13-SC Tampering npm/pip/cargo installs high mitigate Zero packages added in either repo. No install task, so no legitimacy checkpoint required
</threat_model>
- `cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago assistant::` green (S-01, S-02, S-03, S-08, S-09 plus timeout and lock cases) - `cd neode-ui && npx vitest run src/services/__tests__/toolConfirm.test.ts src/services/__tests__/contextBroker.test.ts src/views/__tests__/chatAiuiEmbed.test.ts` green - `cd core && git diff --exit-code -- archipelago/src/api/rpc/dispatcher.rs` exits 0 - On archi-dev-box: deny leaves the container untouched, approve restarts exactly it, reads raise no dialog, and a service restart mid-confirmation executes nothing

<success_criteria> No write reaches a node without a human having approved a node-authored description of that exact action, in a dialog the iframe cannot spoof, restyle or pre-click — demonstrated in code by nonce-binding tests and on a real device by a person reading the dialog. </success_criteria>

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