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 |
|
|
false |
|
|
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_TIMEOUTassistant/loop_.rs: thedestructivebranch ofexecute_toolfilled inapi/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 existingassistant.arm —dispatcher.rsis not touched)
neode-ui
components/ToolConfirmModal.vue(new component)services/contextBroker.ts:handleToolConfirmRequest, theaiui:tool-confirm-request/aiui:tool-confirm-responseCustomEvent pairviews/Chat.vue: the modal mountservices/__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 passinggrep -q 'pub struct PendingConfirmation' core/archipelago/src/assistant/confirm.rsgrep -ciE 'fs::write|save|persist|data_dir' core/archipelago/src/assistant/confirm.rsreturns 0 — the queue has no persistence path (S-09 is structural, not a policy)grep -q 'confirm.request' core/archipelago/src/assistant/loop_.rsand it appears before theexecutecall inexecute_tool— verify by reading the branch ordercd core && git diff --exit-code -- archipelago/src/api/rpc/dispatcher.rsexits 0- Manually flip
restart_app'sdestructiveflag to false and confirmdestructive_tool_requires_confirmgoes 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.
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.tsexits 0 with a test per<behavior>bullet, includingiframe_message_cannot_open_or_resolve_confirmationgrep -q 'Teleport to="body"' neode-ui/src/components/ToolConfirmModal.vuegrep -ci 'postmessage' neode-ui/src/components/ToolConfirmModal.vuereturns 0 — the component has no path from the frame's channelgrep -ci 'v-html' neode-ui/src/components/ToolConfirmModal.vuereturns 0grep -c 'aiui:install-request' neode-ui/src/components/ToolConfirmModal.vuereturns 0 andgrep -c 'aiui:tool-confirm-request' neode-ui/src/services/contextBroker.tsreturns ≥ 1 — a distinct event pair, not the install onegrep -q 'assistant.pending' neode-ui/src/services/contextBroker.ts— the text is RPC-fetchedgrep -q 'ToolConfirmModal' neode-ui/src/views/Chat.vuecd neode-ui && npx vitest run src/services/__tests__/contextBroker.test.ts src/views/__tests__/chatAiuiEmbed.test.tsexits 0 (pre-existing suites still green)cd neode-ui && npx vue-tsc --noEmitexits 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.
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.
- Build and deploy to archi-dev-box per
CLAUDE.md(dev pair before any OTA). Build the frontend withcd neode-ui && npm run buildand grep the built bundle for a string fromToolConfirmModal.vuebefore shipping — the build can silently no-op. Then verify node-side by resolving the live chunk viasw.jsand fetching it over HTTP, not by grepping the node'sassets/directory (it is a never-pruned graveyard and will report "deployed" before the deploy). - Open neode-ui's Chat view, grant the
appscategory, and type a request to restart a specific installed app. - 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.
- 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.
- Deny. Confirm nothing happened to the container and the chat reports the decline honestly rather than claiming it restarted.
- Ask again and approve. Confirm the container actually restarted and the chat reports it.
- Ask for a second, different app. Confirm the two dialogs read differently at a glance.
- Ask a read-only question ("how much space is left"). Confirm no dialog appears.
- 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/*.jsbefore 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 psoutput 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.servicewith 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> |
<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