318 lines
27 KiB
Markdown
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 && CARGO_INCREMENTAL=0 cargo test --package archipelago assistant:: 2>&1 | tail -30</automated>
|
||
|
|
<automated>cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago approval_nonce_binds_to_exact_action</automated>
|
||
|
|
<automated>cd core && 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 && npx vitest run src/services/__tests__/toolConfirm.test.ts</automated>
|
||
|
|
<automated>cd neode-ui && npx vitest run src/services/__tests__/contextBroker.test.ts src/views/__tests__/chatAiuiEmbed.test.ts</automated>
|
||
|
|
<automated>cd neode-ui && 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>
|