← all documents · raw markdown · 15 KB

GUEST ↔ COMMAND-CENTER SEPARATION — GOAL DOC

Status: Phase 1 shipped (v9.10.367). Phases 2–4 staged.

Owner ask (verbatim): *"that's supposed to be separate, in separate file code HTML format. all of that should be separate from guest to command center. it shouldn't be in the same file, because then somebody can hack that in the guest account and then get access to the command center through the guest account. that's a security flaw."*

---

1. The correction that matters

The owner's instinct is right, and the fix shipped. But the reasoning needs one precise amendment, because it changes what we prioritise:

Markup does not grant access. A guest holding <button onclick="confirmRebuild('fleet')"> in their DOM can click it all day; the request lands on POST /api/control/restart, which calls requireControl() and returns 403. Deleting the button does not make the system safer, and keeping it does not make the system exploitable — *provided every privileged endpoint enforces authorization itself.*

So the load-bearing question was never "is the HTML separate?" It was "does the server enforce authorization independently of the UI?" That audit is in §2. It found real holes — and they were not the ones the file-separation instinct would have found.

That said, shipping command-center source to a guest is still wrong, for reasons that are about *disclosure*, not access:

Conclusion: both are worth doing, in this order — server gates first (done, §2), then separation (§3). The separation is defence in depth and anti-disclosure. It is not the access boundary, and it must never be treated as one.

---

2. Server-side authorization audit (v9.10.367)

The pattern that was wrong

Nine host-internal routes carried no gate of their own. Their comments said "behind the auth wall," and they were — but that wall was two blanket checks somewhere else in the file:

1. The global login wall (isAuthorized) — proves you are *someone*, not *who*.

2. The cloud-guest wall (guestApiAllowed + GUEST_API_DENY_PREFIXES) — only fires for role === 'guest'.

That is exactly the fragile shape the owner flagged after the /api/antigravity finding. Three concrete ways it failed:

Fix

New gate, applied per route:

function requireHostRead(req, q, res) { return requireRole(req, q, res, 'viewer'); }

viewer = rank 0, guest = rank −1 → denies guests and unauthenticated callers, while leaving owner/admin/member/viewer exactly as before. Zero behaviour change for any existing non-guest account; immune to both bypasses above.

Audit table

| Capability | Route | Gate before v9.10.367 | Gate now |
|---|---|---|---|
| Fleet/server restart | POST /api/control/restart | requireControl ✅ | unchanged |
| Rebuild & restart (cargo) | /api/control/* | requireControl ✅ | unchanged |
| Raw log tail | GET /api/logs | requireControl ✅ | unchanged |
| Engine tests / sandbox files | GET /api/tests | requireControl ✅ | unchanged |
| Post to #kai-training | POST /api/training-send | requireControl ✅ | unchanged |
| User administration | /api/users, /api/admin/users/plan | requireOwner ✅ | unchanged |
| Federation admin | /api/federation/enable|issue|revoke | requireOwner ✅ | unchanged |
| Host OS config | /api/settings | requireControl ✅ | unchanged |
| Antigravity workspace | /api/antigravity{,/models,/stream} | requireAntigravityWorkspace ✅ | unchanged |
| Operations feed | GET /api/operations | ❌ none | requireHostRead |
| Training pipeline state | GET /api/pipeline | ❌ none | requireHostRead |
| Live training step | GET /api/live-session | ❌ none | requireHostRead |
| Engine vitals | GET /api/vitals | ❌ none | requireHostRead |
| Dreams / consolidation | GET /api/dreams | ❌ none | requireHostRead |
| Training scorecard | GET /api/training | ❌ none | requireHostRead |
| Training feed | GET /api/training-feed | ❌ none | requireHostRead |
| Memory / lattice internals | GET /api/memory | ❌ none | requireHostRead |
| Lattice structure | GET /api/lattice-structure | ❌ none | requireHostRead |
| Discord guild roster | GET /api/guild-members | ❌ none | requireHostRead |
| Federation status | GET /api/federation/status | ❌ none (deny-prefix only) | requireHostRead |

No privileged action was gated only by the UI hiding a button. Every *destructive* or *administrative* capability already had a correct server-side gate. The gap was entirely in host-internal reads — telemetry, memory, training, roster. That is a disclosure problem, not a takeover problem, but it was real and a live tester-guest account could reach all of it.

Information disclosure found and fixed

| Route | Leak | Fix |
|---|---|---|
| GET /api/seen-users | On the guest allowlist, returned records raw: { id, username, display, globalName, avatar, roles, lastSeen, count } — the real Discord snowflake and guild role list of every human ever seen speaking. /api/identities already nulls these via sanitizeIdentitiesForGuest(); this sibling never did. | Guests get { id: null, username, display, lastSeen } only. |
| GET /api/voice-occupants | On the guest allowlist, returned the raw gateway payload enumerating every guild voice channel and its occupants, including work/training rooms. | Channel map filtered to GUEST_PUBLIC_CHANNEL_IDS for guests. |
| GET /api/antigravity | auth_mode / key_present / vertex_project — *(found and fixed in v9.10.360, verified still redacted)* | roleHas(agMe.role,'admin') gate on the full payload. |

Noted, not changed

---

3. Separation architecture

Serve decision: role-based, never host-based

The public site (oraclekai.site) is the guest/member experience; the Tailscale host (john.tail2dd825.ts.net) is for the Command Center. Host informs the default view; role enforces it. A hostname is not a security boundary — the public host must refuse privileged content on its own, because a tunnel misconfiguration, a DNS change, or a proxied header must not be able to hand someone the Command Center. The implemented check reads the *session*, never the Host header.

Classification

| Guest / member only | Genuinely shared | Command Center only |
|---|---|---|
| Social feed, posts, comments, shorts | Login / register portal | Server Control (restart, rebuild, stop, cooldowns) |
| Friends, DMs, people discovery | Base CSS tokens, layout primitives | Test Requests panel |
| Guest dashboard + profile card | Profile card component | Logs / Issues panel |
| Spaces, KAIVERSE places/worlds | Auth + /api/me bootstrap | Memory / Lattice panel |
| Notifications, credits, badges | Toast/modal/accordion primitives | Root Admin, user management |
| Plan/checkout surfaces | KAIVERSE 3D client (kaiverse.js) | Config, Antigravity, fleet ops |
| | | Host telemetry, training classroom |

Phase 1 — SHIPPED (v9.10.367)

Marker-based server-side strip. oracle.html marks CC-only markup:

<!--CC-ONLY:BEGIN name-->  …operator markup…  <!--CC-ONLY:END name-->

stripCcOnlyRegionsFor() removes those regions from the bytes sent to any caller that is not a resolved admin+. A guest's browser never receives the source. This is a server decision, not CSS hiding.

Chosen deliberately over a big-bang split because oracle.html is ~1MB and the project's hard rule is *never whole-file-rewrite* it — wholesale rewrites have caused truncation before.

Safety properties, all covered by test-cc-separation.mjs:

Region marked in phase 1: #acc-control, #acc-tests, #acc-mem, #acc-logs (Server Control, Test Requests, Memory/Lattice, Logs) — ~7KB, verified brace-balanced, and the JS that touches those elements (pollMemory, pollLogs, renderLogs) already null-guards.

Phase 2 — STAGED: mark remaining CC regions

Incrementally wrap, verifying between every step (strip → node --check inline scripts → load as guest and as owner):

1. #col-right operator panels not yet covered

2. .rail host-only buttons (Config, Antigravity, Root Admin)

3. #root-admin block

4. Config and Antigravity view panes

5. Training classroom / host telemetry views

Per region: confirm <div> balance, confirm no guest code path dereferences an element inside it, add a null guard if it does, then wrap and re-run the suite.

Phase 3 — STAGED: split the JavaScript

The strip currently removes CC *markup* but not the CC *functions* — confirmRebuild, pollLogs, renderLogs, hotReloadDashboard and friends still ship to guests as dead code. This is the larger remaining disclosure. Extract them into oracle-cc.js, served by the existing allowlisted-asset route under an admin+ check, and loaded by a <script> inside a CC-ONLY region. command-center-server.mjs already has the asset-allowlist machinery (DASHBOARD_FILE.replace(...)) to do this — the work is the extraction, not the plumbing.

Phase 4 — STAGED: two documents

Once phases 2–3 leave the CC regions cleanly bounded and JS-free, promote the strip into a genuine split: oracle-guest.html and oracle-cc.html, sharing oracle-shared.css and the auth bootstrap. At that point the serve decision selects a file rather than filtering a string, and stripCcOnlyRegionsFor() retires.

Why not now: the JS is not yet separable without touching thousands of lines of a 1MB file that multiple sessions edit concurrently. Attempting it in one pass is exactly the failure mode the no-rewrite rule exists to prevent. Phase 1 closes the disclosure the owner actually saw, at near-zero regression risk, and each later phase is independently verifiable.

---

4. The flash (fixed, v9.10.367)

Cause. The gating model was subtractive and fail-open. <body class="scope-member scope-booting"> ships hardcoded; every CC hide rule keys on body.scope-guest, which only exists after /api/me resolves inside applyScope(). There is no inline <head> script and init() is deferred to oracle-kv-mobile.js, so the browser completes a full paint of the operator UI before any role is known.

The v9.10.343 boot lock was meant to cover this but was an allow-list of four selectors using visibility:hidden. It missed .rail (Config / Antigravity / Root Admin), .col-center/#view-home, and the "Realm Control Center · Command Home · live realm" branding — all painted in full. And visibility:hidden preserves layout, so even the four locked selectors still reserved the operator 288px gutters before collapsing to the guest grid: the *shape* of the Command Center was visible even where its contents were not.

Fix. Enumerating more selectors is just a longer allow-list with the same failure mode. Replaced with a fail-closed boot curtain: one opaque fixed pseudo-element painted above the entire document while the role is unknown. Pure CSS in <head>, so it is in effect on the first paint, before any script runs, and nothing underneath it can be seen regardless of what markup exists or gets added later.

Also fixed the 6-second failsafe, which was itself fail-open: it dropped the lock and revealed the hardcoded scope-member operator shell to whoever was sitting there — including a guest whose /api/me merely timed out. An unresolved role is precisely the case where we know least about the caller, so it now pins scope-guest before lifting the curtain.

---

5. Verification

tools/oracle-discord/test-cc-separation.mjs — 64 assertions, offline (no server spawn, no engine, no Discord), so it cannot be defeated by a service being down:

1. Guest wall refuses every host-internal path.

2. Each host-internal route carries its own gate within 6 lines of the handler — this is the assertion that would have caught the pre-v9.10.367 state.

3. /api/seen-users and /api/voice-occupants redact for guests.

4. Document strip: owner/admin byte-identical; member/viewer/guest/anonymous never receive #acc-control, #acc-tests, #acc-logs, #acc-mem; malformed markers return the original; stripped output is still well-formed and ends </html>.

Run it after any change to the guest wall, the role ranks, or the CC markers.

---

6. Rules for future work

1. Never let a route rely on a wall elsewhere in the file. If it is privileged, it calls its own gate, first line of the handler. Blanket walls are defence in depth, not the gate.

2. The UI is not a boundary. Hiding a button is UX. Assume the attacker has the full document — because until phase 4 they do.

3. Role enforces, host informs. Never gate on Host, X-Forwarded-For, or network origin.

4. New CC surface ⇒ new CC-ONLY region ⇒ new test assertion. All three, same commit.

5. isTester() bypasses the guest wall. Anything a tester must not reach needs its own gate. Re-read that list whenever CC_TESTER_HANDLES changes.