23 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 | 09 | execute | 4 |
|
|
false |
|
|
Delivery (AIUI-05, D-15). AIUI is a *-ui app outside the signed catalog; it reaches nodes
on the frontend rsync, which is how the /assets 404 happened. D-15 keeps the rsync path
because it is the one that works, but makes it deliberate: AIUI's commit pinned in this repo,
VITE_BASE_PATH=/aiui/ enforced by the build script rather than remembered, and a post-deploy
check that fetches a live asset instead of trusting a directory listing. Today
deploy-to-target.sh inlines the base path at line 716 and setup-aiui-server.sh documents it
in a comment — both are the "remembered" form D-15 rejects. Making AIUI a signed-catalog app was
considered and rejected for this phase.
The sandbox (AIUI-04, RESEARCH Open Question 2). Verified: the AIUI iframe in Chat.vue
has no sandbox attribute, is served same-origin under /aiui/, and the site CSP does not
restrict same-origin fetches. So "AIUI never gets an RPC session" is a code-discipline
convention today, not an enforced boundary — AIUI's own JavaScript, running in the operator's
authenticated session, is not browser-prevented from calling /rpc/v1 directly. D-11's whole
premise assumes the postMessage channel is the only channel. This plan makes that true, and the
plan does not claim a property it does not implement.
The mechanism, decided (Open Question 2): a /aiui/-scoped Content-Security-Policy whose
connect-src permits only the AIUI path prefix, plus the G-B3 rate-limit/anomaly counter as the
compensating control. The sandbox attribute is rejected for this phase: AIUI needs
allow-scripts, and allow-scripts together with allow-same-origin is the well-known escape
pattern, while dropping allow-same-origin moves AIUI to an opaque origin and breaks its
storage, its cookies and its origin-checked bridge — a change of a different size than this
phase budgeted. That rejection is recorded here rather than left implicit.
Output: scripts/build-aiui.sh, scripts/verify-aiui-deploy.sh, scripts/aiui.pin, a
/aiui/-scoped CSP, and the deploy path rewired to use them.
<flagged_assumptions>
FLAGGED — unresolved edge probe, AIUI-04, category unclassified. Not auto-resolved and not
auto-backstopped. Surfaced for a human read: AIUI-04's requirement text ("sandboxed by
construction, permissioned by the user") does not itself say what "by construction" must mean —
browser-enforced, or enforced by the node regardless of what the browser does. This plan reads
it as browser-enforced-where-possible plus node-side compensating controls, and says so. If the
intent was a hard origin split (serving AIUI from a different origin entirely), that is a larger
change than this phase scoped and should be raised now rather than at seal time.
FLAGGED — unresolved edge probe, AIUI-05, category unclassified. Not auto-resolved and not
auto-backstopped. Surfaced for a human read: the requirement says AIUI needs "a delivery path an
operator can actually receive updates through", but does not say whether that means the OTA
update path specifically (so an existing node self-updates AIUI), or only that a maintainer
deploy is reliable. This plan delivers the second and makes the first checkable; if the first
is required, it needs an update.rs change this phase has not scoped.
</flagged_assumptions>
<artifacts_this_phase_produces> Symbols created by this plan:
- New file
scripts/build-aiui.sh:require_base_path,pin_commit,verify_dist - New file
scripts/verify-aiui-deploy.sh:resolve_live_chunks,fetch_and_grep - New file
scripts/aiui.pin(data: branch + commit SHA) image-recipe/configs/nginx-archipelago.conf: aContent-Security-Policyheader on thelocation /aiui/blocks (both server blocks)neode-ui/src/views/Chat.vue: areferrerpolicyattribute and an explanatory comment on the iframe recording whysandboxis absentscripts/deploy-to-target.sh: call sites for the two new scripts, replacing the inline build </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-RESEARCH.md @.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-02-SUMMARY.md Task 1: Make the sandbox an enforced boundary, and say exactly what it enforces image-recipe/configs/nginx-archipelago.conf, neode-ui/src/views/Chat.vue - `image-recipe/configs/nginx-archipelago.conf` lines 36-48 and 955-962 — **both** `location /aiui/` blocks, and the existing site-wide CSP wherever it is set. A change to one block only leaves the boundary open on whichever block serves the request. - `neode-ui/src/views/Chat.vue` lines 33-42 — the iframe element: `:src="aiuiUrl"`, `allow="microphone"`, no `sandbox`. - `.planning/phases/13-.../13-RESEARCH.md` Pitfall 2 ("Assuming the iframe boundary is a hard sandbox") in full, and Open Question 2. - `.planning/phases/13-.../13-AI-SPEC.md` §6 "Residual risks" — the first row is exactly this, and names G-B3 as the compensating control. - `/home/archipelago/Projects/AIUI/packages/app/src/services/archyBridge.ts` — what AIUI actually needs to reach at runtime when embedded, so the policy does not break it. Add a `Content-Security-Policy` response header to **both** `location /aiui/` blocks. Its `connect-src` directive permits `'self'`-equivalent access only under the AIUI path prefix, built from nginx's `$scheme` and `$host` variables so it stays correct across http/https, LAN IP, hostname, Tailscale and onion access. Include `blob:` and `data:` where AIUI's runtime needs them, keep `script-src`/`style-src`/`img-src`/`font-src`/`media-src` permissive enough that the existing bundle still runs, and set `frame-ancestors` to the node's own origin so the AIUI document cannot itself be framed by a third party. The load-bearing directive is `connect-src`: it must not include a source expression that resolves to `/rpc/v1`.Add a comment above the header stating in one sentence what the policy does and does not guarantee — that it prevents AIUI's own JavaScript from issuing a same-origin fetch to the RPC surface, and that it is not an origin split. The previous comment in this file ("no session gate needed") is the reasoning error that produced 13-02's exposure; do not leave a comment here that could be read the same optimistic way.
In Chat.vue, do not add a sandbox attribute. Add referrerpolicy="no-referrer" to the
iframe (so a media URL or a page path never leaks upstream through a Referer header) and a
comment above the element recording, in three lines: that sandbox was considered and rejected
for this phase; that allow-scripts + allow-same-origin together is a known escape while
dropping allow-same-origin breaks AIUI's storage and its origin-checked bridge; and that the
enforced boundary is the /aiui/-scoped CSP plus the node-side rate limit, with the residual
risk named in 13-AI-SPEC.md §6.
Do not claim more than this implements. If any acceptance check below fails on device, the correct outcome is to record the residual risk explicitly rather than to relax the check. grep -c 'Content-Security-Policy' image-recipe/configs/nginx-archipelago.conf | grep -qvx 0 cd neode-ui && npx vitest run src/views/tests/chatAiuiEmbed.test.ts && npx vue-tsc --noEmit grep -c 'no session gate needed' image-recipe/configs/nginx-archipelago.conf | grep -qx 0 <acceptance_criteria>
grep -c 'Content-Security-Policy' image-recipe/configs/nginx-archipelago.confreturns 2 — one per server block- The CSP's
connect-srcvalue contains the AIUI path prefix and does not contain a bare'self'— verify by reading the directive grep -q 'referrerpolicy' neode-ui/src/views/Chat.vuegrep -ci 'sandbox=' neode-ui/src/views/Chat.vuereturns 0, and the comment explaining why is presentgrep -c 'no session gate needed' image-recipe/configs/nginx-archipelago.confreturns 0cd neode-ui && npx vitest run src/views/__tests__/chatAiuiEmbed.test.tsexits 0- On a deployed node,
fetch('/rpc/v1', {method:'POST'})executed from the AIUI frame's console is blocked by CSP and logs a violation; the same fetch from the top-level neode-ui console succeeds (recorded in Task 3) </acceptance_criteria> This is the enforcement mechanism AIUI-04's "sandboxed by construction" claim rests on. A CSP header is a config change and reverting is trivial, but the claim it supports is load-bearing for D-11's threat model — weakening it later silently invalidates the phase's security story rather than just its config. Flagged, not gated. AIUI's document carries a policy that browser-prevents a direct RPC fetch, both nginx server blocks carry it, and the iframe records whysandboxis absent rather than implying it is present.
require_base_path exits non-zero with a plain-language message when VITE_BASE_PATH is unset or is not exactly the AIUI mount path. The script sets it itself for the normal case; the check exists so an operator overriding it with a wrong value fails loudly instead of shipping a black page. D-15's point is that the requirement is enforced, not documented.
pin_commit reads scripts/aiui.pin (a two-line file: branch, then commit SHA), checks out that commit in the AIUI working tree, and refuses to proceed if the tree is dirty — a build from an uncommitted AIUI tree cannot be reproduced or attributed. Add a --update-pin flag that rewrites the pin from the AIUI tree's current HEAD, so bumping the pin is a deliberate, committed act in this repo. Create scripts/aiui.pin with AIUI's development branch and its current HEAD.
The build runs AIUI's real command (vue-tsc --noEmit && vite build) so a type error fails the build rather than producing a stale dist.
verify_dist then asserts, before anything is copied anywhere: dist/index.html exists; every <script>/<link> href in it begins with the AIUI mount path (a hand-built bundle with the wrong base path gives a black page, and the router base is what actually breaks, not the assets); the built asset filenames differ from the previous build when the source changed; and the pinned commit SHA appears somewhere in the emitted output so a deployed node can be attributed. Emit the SHA as a build-time define or a small dist/BUILD-INFO file, whichever is simpler in this build.
Rewire scripts/deploy-to-target.sh to call scripts/build-aiui.sh instead of building inline, and to call scripts/verify-aiui-deploy.sh after the copy. Keep the existing demo/aiui/ fallback path but make it print a loud warning naming that it is shipping a checked-in dist rather than a fresh build, so that path stops being silent.
Also update scripts/setup-aiui-server.sh's comments to point at build-aiui.sh rather than restating the env var.
bash -n scripts/build-aiui.sh && bash -n scripts/deploy-to-target.sh
VITE_BASE_PATH=/wrong/ bash scripts/build-aiui.sh; test $? -ne 0
bash scripts/build-aiui.sh && grep -c 'src="/aiui/' /home/archipelago/Projects/AIUI/packages/app/dist/index.html | grep -qvx 0
<acceptance_criteria>
bash -n scripts/build-aiui.shexits 0 and the file is executableVITE_BASE_PATH=/wrong/ bash scripts/build-aiui.shexits non-zero with a message naming the required valuescripts/aiui.pinexists and contains the branch name and a 40-character commit SHA- Running the script with a dirty AIUI tree exits non-zero
- After a successful run, every
src=/href=in/home/archipelago/Projects/AIUI/packages/app/dist/index.htmlstarts with the AIUI mount path —grep -cE '(src|href)="/(?!aiui/)' dist/index.htmlfinds no non-AIUI-prefixed local asset - The pinned SHA is discoverable in the built output (
grep -rq "<pinned-sha>" dist/) grep -c 'build-aiui.sh' scripts/deploy-to-target.shreturns ≥ 1 andgrep -c 'VITE_BASE_PATH=/aiui/ pnpm build' scripts/deploy-to-target.shreturns 0 — the inline build is gone </acceptance_criteria> A wrong base path, a dirty AIUI tree, or a type error each fail the build loudly; a successful build is attributable to a pinned commit recorded in this repo.
This exists because the node's assets/ directory is a never-pruned graveyard: a disk grep over
it reports "deployed" before the deploy, because a dead chunk from an old build still contains
the string. The only honest check fetches what the browser would actually load.
- Write
scripts/verify-aiui-deploy.shfollowingtests/production-quality/lnd-cors-test.sh's shape. It takes a host and an expected marker string, fetches the service worker manifest over HTTP to resolve live chunk URLs, fetches each, and greps the fetched bytes. It exits non-zero when the marker is absent from every live chunk. It must not ssh in and grep/opt/archipelago/web-ui/aiui/assets/. - Build with
bash scripts/build-aiui.shand deploy to archi-dev-box perCLAUDE.md(dev pair before any OTA). - Run
bash scripts/verify-aiui-deploy.sh <node> "<a string only the new build contains>". Expect exit 0. - Negative control: run it again with a string that does not exist in any build. Expect a non-zero exit. A check that always passes is not a check.
- Load neode-ui's Chat view on that node in a desktop browser. Confirm AIUI renders — not a
black page. A black page means the router base broke; confirm by fetching the node's
/aiui/index.htmland reading its asset hrefs. - Open the browser devtools console inside the AIUI frame and attempt a POST to
/rpc/v1. Confirm the browser blocks it with a CSP violation. Then run the same fetch from the top-level neode-ui frame and confirm it succeeds — that difference is the boundary this plan claims, and step 6 is the only place it is actually observed. - Exercise the embedded chat and one content grid to confirm the CSP did not break AIUI's own runtime. <acceptance_criteria>
bash scripts/verify-aiui-deploy.sh <node> "<new-build marker>"exits 0- The same script with a non-existent marker exits non-zero (negative control recorded)
grep -c 'sw.js' scripts/verify-aiui-deploy.shreturns ≥ 1 andgrep -ci 'ssh' scripts/verify-aiui-deploy.shreturns 0 — the check is an HTTP fetch, not a disk grepcurl -s http://<node>/aiui/index.html | grep -c 'src="/aiui/'returns ≥ 1- AIUI renders in the embedded iframe on the node — not a black page (screenshot in the summary)
- A
fetch('/rpc/v1', {method:'POST'})from inside the AIUI frame is blocked with a CSP violation; the same call from the top-level frame succeeds (both console outputs recorded in the summary) - Embedded chat still answers and one content grid still populates after the CSP landed </acceptance_criteria> Type "approved" with the two console results from step 6, or describe what the CSP broke.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
AIUI document → /rpc/v1 |
The boundary this plan enforces. Same-origin today, so only a policy can stop it |
| maintainer workstation → node filesystem | The rsync deploy path; what lands is what runs |
| AIUI repo → Archy build | A second repository's HEAD becomes part of this repo's shipped artifact |
node assets/ → verification |
The graveyard that makes a disk grep lie |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-13-54 | Elevation of Privilege | AIUI's JS calling /rpc/v1 with the ambient session cookie |
high | mitigate | /aiui/-scoped CSP connect-src excluding the RPC path; verified in the browser, per-frame, in Task 3 step 6. sandbox explicitly rejected with reasons recorded |
| T-13-55 | Elevation of Privilege | Residual: a browser that ignores or partially enforces CSP | medium | accept | Named residual (AI-SPEC §6 row 1). Compensating control is G-B3's rate limit and anomaly counter on assistant.chat, landing in 13-12. Recorded, not silently assumed away |
| T-13-56 | Information Disclosure | Media URL or page path leaking upstream via Referer | medium | mitigate | referrerpolicy="no-referrer" on the iframe; complements 13-06's no-credential-in-URL rule |
| T-13-57 | Tampering | A wrong VITE_BASE_PATH ships a black page to every node |
high | mitigate | require_base_path exits non-zero; verify_dist asserts every asset href carries the mount path before anything is copied |
| T-13-58 | Tampering | An unattributable AIUI build from a dirty second-repo tree | medium | mitigate | scripts/aiui.pin + refuse-on-dirty + the SHA emitted into the built output |
| T-13-59 | Repudiation | A disk grep over the node's asset graveyard reports a deploy that did not happen | high | mitigate | verify-aiui-deploy.sh resolves live chunks via the service worker manifest and greps the fetched bytes; asserted by the no-ssh grep and by a negative control |
| T-13-60 | Denial of Service | CSP breaks AIUI's runtime and the chat surface goes dark | medium | mitigate | Task 3 steps 5 and 7 exercise chat and a content grid after the policy lands; a break is recorded as a residual rather than papered over by relaxing the check |
| T-13-SC | Tampering | npm/pip/cargo installs | high | mitigate | Zero packages added. The build script runs AIUI's existing pnpm/vite toolchain and installs nothing new. No install task, so no legitimacy checkpoint required |
| </threat_model> |
<success_criteria> AIUI cannot be built wrong silently, cannot be deployed unverifiably, and cannot reach the RPC surface from inside its own frame — and where the boundary is not absolute, the plan says so in the config comment, in the iframe comment and in the threat register rather than claiming a property it did not implement. </success_criteria>
Create `.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-09-SUMMARY.md` when done