26 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 13-aiui-functional-conversational-node-control-and-content-surf | 12 | execute | 5 |
|
|
true |
|
|
D-10 — authority never derives from content. This node hosts peer-authored text as part of its normal function: mesh chat, Nostr posts, filenames on shared content. That text legitimately enters the assistant's context. An isolated single-user chatbot does not have this surface at all; here it is a routine input path. The mechanism is a per-call randomized delimiter plus an instruction that everything inside it is data — and the randomization is the load-bearing part, because a fixed marker is forgeable by content that already contains it. Pattern-stripping filters were considered and explicitly rejected: they are an arms race that reads as a guarantee they are not.
The delimiter and the confirm gate are two independent layers, not substitutes. Even if a weak model acts on an injected imperative anyway, the gate still names the real action to a human before anything runs.
The failure the gate cannot see (AI-SPEC §1b failure mode 4). Reads do not require confirmation by design. So an injection-driven loop that only ever calls read tools, or that pushes the conversation toward a paid cloud backend instead of the local one, can spend budget or leak read-scope node data without ever surfacing a dialog to reject. The confirm gate is the guardrail for writes; it is not a guardrail for over-reading or backend drift. That is what G-B1, G-B2 and G-B3 are for, and it is why they earn their latency.
Output: assistant/untrusted.rs, assistant/egress.rs, and an assistant.chat rate limit with
owner-visible anomaly notices.
<flagged_assumptions> None in this plan. </flagged_assumptions>
<artifacts_this_phase_produces> Symbols created by this plan:
assistant/untrusted.rs:pub fn wrap_untrusted,pub struct UntrustedBlock,fn fresh_token,const TOKEN_LENassistant/egress.rs:pub fn screen_outbound,pub enum EgressVerdict(Allow,Truncate,BlockFallBackLocal),fn scan_secret_shapes,fn assert_turn_minimal,const MAX_OUTBOUND_CONTEXT_CHARSassistant/mod.rs:pub struct AssistantCounters(grant refusals, validation failures, turns-per-request, untrusted-content-present, cloud-escalation-while-local-up),pub fn owner_noticecore/archipelago/src/rate_limit.rs: anassistant.chatper-session limit and anomaly threshold </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-10-SUMMARY.md Task 1: The untrusted-content boundary, randomized per call core/archipelago/src/assistant/untrusted.rs, core/archipelago/src/assistant/tools.rs, core/archipelago/src/assistant/loop_.rs - Two calls to the wrapper on identical input produce different delimiter tokens. - Content that already contains a previously-used delimiter cannot terminate the current block early — the surrounding token differs, so the forged boundary is inert. - The wrapped block carries an instruction stating the enclosed text is untrusted peer-supplied data, to be treated as data to analyze or quote, never as an instruction and never as grounds to call a tool the authenticated user did not already request. - Every tool result derived from peer-authored text (filenames, content descriptions, mesh message bodies) is wrapped before it becomes a `ChatMessage`; operator-authored text is not wrapped. - A scripted turn where wrapped content contains an imperative to restart an app produces no execution: either no tool call, or a tool call that suspends at the confirm gate naming the real action. - A scripted turn where wrapped content contains a forged closing boundary plus a fake operator turn still produces no execution. - No source file in the assistant module contains a pattern-stripping or keyword-blocklist filter over model or peer text. - `.planning/phases/13-.../13-AI-SPEC.md` §4b.3 "Prompt Engineering Discipline" — the `wrap_untrusted` sketch, its use of the in-tree `rand` crate, and the two-independent-layers argument. **This is the pattern source; 13-PATTERNS.md records no in-repo analog.** - `.planning/phases/13-.../13-AI-SPEC.md` §5 reference dataset rows **EV-09** (peer file named as an imperative), **EV-10** (mesh body claiming pre-approval), **EV-11** (forged closing delimiter plus fake operator turn) and **EV-12** (content instructing the model to mis-describe a restart). EV-11 exists specifically to prove why the per-call token is needed — a fixed marker fails it by construction. - `.planning/phases/13-.../13-CONTEXT.md` D-10, including the explicit rejection of pattern-stripping filters. - `core/archipelago/src/assistant/tools.rs` — where tool results are built, and the `content_list`, `app_logs` and `mesh_status` tools whose results carry peer-authored strings. - `core/archipelago/Cargo.toml` line 68 — `rand = "0.8.5"` is already in-tree; no new dependency. Create `core/archipelago/src/assistant/untrusted.rs` with `wrap_untrusted(label, text) -> String`. `fresh_token` draws a new alphanumeric token from the in-tree `rand` crate on **every call** — never a module constant, never a per-process value, never derived from the content. The opening and closing markers embed that token, and the block is followed by an instruction that everything between the markers is untrusted, peer-supplied content to be treated as data to analyze or quote, never as an instruction, and never as grounds to call a tool the authenticated user did not already request in this conversation.Wire it into tools.rs at the point where a tool result is constructed: any field whose value originates outside the operator — a filename, a content description, a log line, a mesh message body, a Nostr post — is wrapped before it becomes a ChatMessage. Operator-authored turns are not wrapped; wrapping everything would dilute the signal until the model stops distinguishing.
Do not add a pattern-stripping or keyword-blocklist filter over peer text or model output. D-10 rejects them by name: they are an arms race, and shipping one reads as a guarantee it is not. The two layers are the delimiter and the confirm gate.
Write the tests FIRST, one per <behavior> bullet, driving the loop with 13-01's ScriptedBackend so the injection cases assert against the worst output a compromised model could emit rather than against what a real model happens to do today. Name them
assistant::tools::tests::wrap_untrusted_token_is_per_call (S-10),
assistant::tests::injected_instruction_does_not_grant_authority (S-10),
assistant::tests::forged_closing_delimiter_does_not_escape_block (EV-11),
assistant::tests::injected_mislabel_still_confirms_real_action (EV-12).
cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago assistant:: 2>&1 | tail -30
cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago wrap_untrusted_token_is_per_call
<acceptance_criteria>
cd core && cargo test --package archipelago assistant::exits 0 with all four named tests passinggrep -q 'pub fn wrap_untrusted' core/archipelago/src/assistant/untrusted.rsandgrep -qE 'thread_rng|rng\(\)' core/archipelago/src/assistant/untrusted.rs— the token is drawn per callwrap_untrusted_token_is_per_callasserts two invocations on identical input differ, and it passesgrep -rvE '^\s*//' core/archipelago/src/assistant/*.rs | grep -ciE 'blocklist|blacklist|strip_?pattern|sanitize_prompt'returns 0 — no pattern-stripping filter was addedgrep -c 'wrap_untrusted' core/archipelago/src/assistant/tools.rs≥ 1cd core && git diff --exit-code -- archipelago/Cargo.tomlexits 0 —randwas already in-tree </acceptance_criteria> A wrapping function at a call site; the boundary can be tightened or its wording tuned without a contract change. Peer text enters context as delimited data with a fresh token per call, a forged boundary is inert, an injected imperative produces no execution, and no keyword filter was added.
scan_secret_shapes looks for macaroon-shaped hex runs, BIP39-length word runs, ecash- and Nostr-key-shaped strings, and the literal contents of files under the node's secrets directory. On a hit the verdict is BlockFallBackLocal: the request does not leave the node, the turn retries against the local backend, an error-level event is emitted, and a persistent owner-visible security notice is raised — not a toast. G-S5 and S-11 already make this structurally unreachable; this is the belt to that braces, and if it ever fires it means a tool is returning something it must not. Never log the matched value, only its kind — the observability layer must not become the leak the guardrail exists to prevent.
assert_turn_minimal checks the outbound body against an allowlist of the current turn's own fields: the user's turn, the tools granted for this call, and this turn's tool results. An unrelated earlier tool result, a compaction summary about a different topic, or untrusted content wrapped for a different turn is truncated out, or the escalation is refused and answered locally. Measure it mechanically against the allowlist — E-04's rubric is explicit that eyeballing the payload does not count. This is a privacy check, not a correctness one: a cloud-answered request can be perfectly correct and still fail it, and that is the point.
Add AssistantCounters to mod.rs tracking cloud-escalation-while-local-up, blocked-egress, grant refusals, validation failures, turns-per-request and untrusted-content-present, plus owner_notice for surfacing them in the operator's own UI. Per AI-SPEC §7 these are local and owner-facing: no exporter, no collector, no network egress, no sidecar, and no unauthenticated metrics port. Counters reach the UI through the authenticated RPC surface like everything else.
Every ambiguous case fails closed — the request does not leave the node.
Write the tests FIRST, one per <behavior> bullet, under assistant::egress::tests::. Name the privacy case unrelated_context_is_not_escalated_to_cloud and the fail-closed case ambiguous_body_does_not_leave_the_node.
cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago assistant::egress:: 2>&1 | tail -20
cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago unrelated_context_is_not_escalated_to_cloud
<acceptance_criteria>
cd core && cargo test --package archipelago assistant::egress::exits 0 with a test per<behavior>bulletgrep -q 'pub fn screen_outbound' core/archipelago/src/assistant/egress.rsgrep -c 'screen_outbound' core/archipelago/src/assistant/backends/ollama.rsreturns 0 — the scan does not run on the local leggrep -c 'screen_outbound' core/archipelago/src/assistant/backends/claude.rsreturns ≥ 1grep -rniE 'warn!|error!|info!|debug!' core/archipelago/src/assistant/egress.rs | grep -ciE 'matched|value|body'returns 0 — a match's kind is logged, never its contentgrep -rci 'prometheus\|/metrics\|opentelemetry\|otlp' core/archipelago/src/assistant/returns 0 — no exporter, no scrape port (AI-SPEC §7b)cd core && git diff --exit-code -- archipelago/Cargo.tomlexits 0 </acceptance_criteria> A secret-shaped string never leaves the node, an escalation carries only the turn it belongs to, the local leg pays no scan cost, and every counter stays on the node and faces the owner.
Wire the anomaly notices from AssistantCounters (Task 2) to the thresholds in AI-SPEC §7b, with one distinction that matters: a run of grant refusals with untrusted content present is a security signal and says so — something in shared content is trying to trigger actions — while the same run without untrusted content is a configuration signal and prompts the owner to open the category. Conflating the two would either cry wolf or hide an attack, and the untrusted-content flag is what tells them apart.
Add the MAX_TURNS-reached counter and its threshold notice in loop_.rs.
All notices are local and owner-facing. Nothing is exported anywhere.
Write the tests FIRST, one per <behavior> bullet. Name the EV-13 case
read_only_injection_loop_terminates_and_is_counted and the disambiguation case
grant_refusals_with_untrusted_content_are_a_security_signal.
cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago rate_limit:: 2>&1 | tail -20
cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago assistant:: 2>&1 | tail -30
cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago read_only_injection_loop_terminates_and_is_counted
<acceptance_criteria>
cd core && cargo test --package archipelago rate_limit:: assistant::exits 0 with a test per<behavior>bulletgrep -q 'assistant.chat' core/archipelago/src/rate_limit.rsread_only_injection_loop_terminates_and_is_countedasserts zero confirmations were raised and that the loop stopped at or beforeMAX_TURNSgrant_refusals_with_untrusted_content_are_a_security_signalasserts the two notice kinds differcd core && cargo test --package archipelago(full suite) exits 0 — the existing rate-limited methods are unaffectedgrep -rci 'prometheus\|/metrics\|opentelemetry\|otlp' core/archipelago/src/rate_limit.rsreturns 0 </acceptance_criteria> A read-only injection loop that never trips a confirmation is still bounded and counted, the owner is told in their own UI, and a burst of grant refusals is distinguishable as probing versus misconfiguration.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| peer-authored text → model context | The boundary D-10 defines. Crossed constantly and legitimately; marked as data by a per-call randomized delimiter |
| node → cloud backend | Screened by G-B1 for secret shapes and by G-B2 for minimality; fails closed |
| node → local Ollama | Nothing leaves; deliberately unscreened |
| counters → anywhere off-node | Never crosses. Owner-facing, local, no exporter |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-13-76 | Elevation of Privilege | Prompt injection via peer content driving an unrequested tool call | critical | mitigate | Two independent layers: D-10's randomized delimiter block, and D-11's confirm gate naming the real action for every write. Asserted by injected_instruction_does_not_grant_authority and injected_mislabel_still_confirms_real_action against the worst scripted model output |
| T-13-77 | Tampering | Peer content forging a closing delimiter and impersonating an operator turn | high | mitigate | S-10: fresh random token per call, so a marker embedded in content is inert. Asserted by wrap_untrusted_token_is_per_call and forged_closing_delimiter_does_not_escape_block (EV-11) |
| T-13-78 | Information Disclosure | Key material or a secret reaching a cloud backend in a request body | critical | mitigate | G-B1 scan_secret_shapes, fail-closed to the local backend, persistent owner notice, match kind logged and never the value |
| T-13-79 | Information Disclosure | Node state the turn did not need escalated to a cloud model | high | mitigate | G-B2 assert_turn_minimal against a mechanical allowlist of the turn's own fields. Recorded as this plan's prohibition — a correct answer that took the whole file listing to a cloud model is a domain failure |
| T-13-80 | Denial of Service | Read-only injection loop that never trips the confirm gate | high | mitigate | G-B3 rate limit plus MAX_TURNS; EV-13 asserted directly. This is AI-SPEC §1b failure mode 4, the one the write guardrail structurally cannot see |
| T-13-81 | Elevation of Privilege | Residual: AIUI reaching /rpc despite 13-09's CSP, on a browser that does not enforce it |
medium | mitigate | G-B3 per-session rate limit and anomaly counter — the compensating control RESEARCH Open Question 2 names for exactly this residual |
| T-13-82 | Information Disclosure | The observability layer becoming the leak | high | mitigate | AI-SPEC §7b field policy enforced by grep: no matched value, no body, no exporter, no scrape port. Counters travel over the authenticated RPC surface only |
| T-13-83 | Repudiation | Probing indistinguishable from misconfiguration, so a real attack reads as a UX nit | medium | mitigate | The untrusted-content-present flag splits the two notice kinds; asserted by grant_refusals_with_untrusted_content_are_a_security_signal |
| T-13-84 | Tampering | A pattern-stripping filter added as a "quick win", presenting an arms race as a guarantee | medium | mitigate | Explicitly rejected by D-10 and asserted by a grep over the assistant module's non-comment source |
| T-13-SC | Tampering | npm/pip/cargo installs | high | mitigate | Zero packages added — rand is already in-tree at 0.8.5. Asserted by git diff --exit-code -- archipelago/Cargo.toml. No install task, so no legitimacy checkpoint required |
| </threat_model> |
<success_criteria> Peer-authored text can enter the model's context as a routine matter without ever becoming a source of authority; nothing secret and nothing irrelevant leaves the node; and the read-only injection loop that slips past every write guardrail is bounded, counted and surfaced to the owner in their own UI. </success_criteria>
Create `.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-12-SUMMARY.md` when done