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."*
---
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:
cargo build --release, overnight_pipeline.log, the fleet topology).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.
---
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:
isTester() bypasses the guest wall wholesale. CC_TESTER_HANDLES defaults to kaitestguest — a real guest account in state/cc_users.json right now. That account reached /api/memory, /api/lattice-structure, /api/training, /api/operations, /api/vitals, /api/dreams, /api/pipeline, /api/live-session and /api/guild-members with nothing at all checking it. The tester bypass was built to preview *paid tiers*; because these routes had no gate, it also handed over host internals.CC_GUEST_WALL=0 disables the wall and takes all nine with it.member and viewer roles were never subject to the guest wall in the first place, so they read host telemetry unchecked.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.
POST /api/control/restart | requireControl ✅ | unchanged |/api/control/* | requireControl ✅ | unchanged |GET /api/logs | requireControl ✅ | unchanged |GET /api/tests | requireControl ✅ | unchanged |POST /api/training-send | requireControl ✅ | unchanged |/api/users, /api/admin/users/plan | requireOwner ✅ | unchanged |/api/federation/enable|issue|revoke | requireOwner ✅ | unchanged |/api/settings | requireControl ✅ | unchanged |/api/antigravity{,/models,/stream} | requireAntigravityWorkspace ✅ | unchanged |GET /api/operations | ❌ none | requireHostRead |GET /api/pipeline | ❌ none | requireHostRead |GET /api/live-session | ❌ none | requireHostRead |GET /api/vitals | ❌ none | requireHostRead |GET /api/dreams | ❌ none | requireHostRead |GET /api/training | ❌ none | requireHostRead |GET /api/training-feed | ❌ none | requireHostRead |GET /api/memory | ❌ none | requireHostRead |GET /api/lattice-structure | ❌ none | requireHostRead |GET /api/guild-members | ❌ none | requireHostRead |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.
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. |GET /api/public-url returns the Cloudflare tunnel hostname to guests. It is behind the login wall and the tunnel host *is* the public entry point, so this is low-risk — but it is host infrastructure detail on a guest allowlist. Flag for a decision./api/node/*** authenticates by x-node-token *before* the session wall by design (nodes are not browsers). Correct, but it means these routes' safety rests entirely on federation.extractNodeToken() + per-call validation inside shared/federation.mjs. Worth a dedicated review pass.requireHostRead is set at viewer, deliberately. That preserves today's behaviour exactly. Whether member/viewer *should* read host telemetry at all is a product decision, not a regression fix — if the answer is no, change one line to 'admin'. Recommended once the viewer portal's needs are confirmed.pollTests() in oracle.html dereferences $('test-list') without a null guard. Currently dead code (no callers), so harmless, but it would throw if wired up after the strip removes that element.---
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.
/api/me bootstrap | Memory / Lattice panel |kaiverse.js) | Config, Antigravity, fleet ops |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.
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.
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.
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.
---
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.
---
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.
---
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.