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

318 lines
27 KiB
Markdown

---
phase: 13-aiui-functional-conversational-node-control-and-content-surf
plan: 08
type: execute
wave: 3
depends_on: ["13-05"]
files_modified:
- 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
autonomous: false
requirements: [AIUI-01, AIUI-04]
must_haves:
truths:
- "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"
prohibitions:
- statement: "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."
status: active
verification: unverified
artifacts:
- path: "core/archipelago/src/assistant/confirm.rs"
provides: "D-11 pending-confirmation queue: node-authored description, nonce binding, in-memory only"
contains: "pub struct PendingConfirmation"
- path: "neode-ui/src/components/ToolConfirmModal.vue"
provides: "Trusted-chrome approve/deny modal, Teleport to body, RPC-fetched text"
contains: "Teleport"
key_links:
- from: "core/archipelago/src/assistant/loop_.rs"
to: "core/archipelago/src/assistant/confirm.rs"
via: "execute_tool suspends on ctx.confirm.request(tool, &args) before (tool.execute)"
pattern: "confirm\\.request"
- from: "neode-ui/src/components/ToolConfirmModal.vue"
to: "core/archipelago/src/api/rpc/assistant_chat.rs"
via: "assistant.confirm-tool over the page's authenticated RPC session, carrying the node-minted nonce"
pattern: "assistant\\.confirm-tool"
---
<objective>
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`.
</objective>
<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>
<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
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: The gate — node-authored text, nonce-bound approval, in-memory only</name>
<files>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</files>
<behavior>
- 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.
</behavior>
<read_first>
- `.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.
</read_first>
<action>
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`.
</action>
<verify>
<automated>cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo test --package archipelago assistant:: 2>&amp;1 | tail -30</automated>
<automated>cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo test --package archipelago approval_nonce_binds_to_exact_action</automated>
<automated>cd core &amp;&amp; git diff --exit-code -- archipelago/src/api/rpc/dispatcher.rs</automated>
</verify>
<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>
<reversibility rating="costly">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.</reversibility>
<done>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.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: The trusted chrome — a modal the iframe cannot reach</name>
<files>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</files>
<behavior>
- 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.
</behavior>
<read_first>
- `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.
</read_first>
<action>
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`.
</action>
<verify>
<automated>cd neode-ui &amp;&amp; npx vitest run src/services/__tests__/toolConfirm.test.ts</automated>
<automated>cd neode-ui &amp;&amp; npx vitest run src/services/__tests__/contextBroker.test.ts src/views/__tests__/chatAiuiEmbed.test.ts</automated>
<automated>cd neode-ui &amp;&amp; npx vue-tsc --noEmit</automated>
</verify>
<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>
<done>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.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Look at the dialog — anti-spoofing and clear-signing are things you see</name>
<what-built>
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.
</what-built>
<how-to-verify>
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.
</how-to-verify>
<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>
<resume-signal>Type "approved" and paste the exact dialog text you saw, or describe what read wrong.</resume-signal>
</task>
</tasks>
<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>
<verification>
- `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
</verification>
<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>
<output>
Create `.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-08-SUMMARY.md` when done
</output>