EVL-143: enforce disable_clipboard_copy_cut in the React shell - #1672
Draft
rogefm wants to merge 4 commits into
Draft
EVL-143: enforce disable_clipboard_copy_cut in the React shell#1672rogefm wants to merge 4 commits into
rogefm wants to merge 4 commits into
Conversation
The org setting is a security control, but it was only ever enforced by the CLJS bundle — which ClojureApp injects lazily, on the catch-all route. A session that stays on React routes never ran :clipboard/initialize, so the setting was silently not applied. Adds utils/clipboardPolicy.js (verbatim message, 2s cooldown, refcounted install, guarded copyToClipboard) and hooks/useClipboardGuard.js, called once from App.jsx. App and not Layout: Layout does not wrap /onboarding/* (a CLJS route) or /onboarding/protection-rules, so a Layout mount would leave a CLJS route enforcing nothing once the CLJS listeners are removed. The listeners run in the capture phase and stopImmediatePropagation, one line stronger than the CLJS original: per the Clipboard API spec a canceled copy event still writes whatever a downstream handler put in clipboardData via setData(), which is exactly what CodeMirror 6 does — bubble-phase alone leaves right-click > Copy working inside the webclient editor. Also closes two paths document listeners cannot see: - pages/Agents/Create/DeploymentInstructions.jsx used a local InlineCopy on Mantine's CopyButton, ungated — it copied the agent deploy secret with the control on. The CLJS equivalent (agents/deployment.cljs:104) does gate. Now uses @/components/CopyButton, which renders nothing when the flag is set. - features/CommandPalette HOOP_CLI routed navigator.clipboard.writeText directly. Now goes through copyToClipboard, and no longer swallows a rejected write. Two supporting fixes, without which single ownership would be a net coverage reduction: - useAuthStore.logout() now calls useUserStore.clear(), which had zero call sites. Sidebar logout is a soft SPA navigation, so re-logging into another org in the same tab inherited the previous org's flags and role. - ProtectedRoute retries /serverinfo on navigation while it is unloaded. Its existing retry lives in a once-per-instance effect and React Router reuses the instance, so a transient failure left every serverinfo-derived control (license gating included) off for the whole session. CLJS got this for free by refetching /serverinfo from six places. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deletes webapp.events.clipboard, its side-effect require in app.cljs and the :clipboard/initialize dispatch in the ::gateway->set-info handler. All three must land together — shadow-cljs fails to compile a require of a missing namespace. Keeping both sides would mean two listener sets with two independent cooldowns, so one Cmd+C could produce two toasts on a catch-all route. The namespace was fully orphaned by the dispatch removal: it registered no subscription, :clipboard/update-state was already dead code, and :clipboard/manage-listeners was only consumed from inside the file. Deliberately untouched: the :gateway->clipboard-disabled? sub, which is defined in gateway_info.cljs (not in the deleted namespace) and still feeds its 9 view consumers; and the CodeMirror Mod-c/Mod-x keymap in webclient/panel.cljs, which is Wave 7 territory. No analytics impact: the deleted namespace emitted no track() call, there is no clipboard constant in gateway/analytics/events.go, and the :fx vector keeps :tracking->initialize-if-allowed and :initialize-monitoring. Verified: shadow-cljs release hoop-ui compiles with 0 warnings, and `grep -rn "clipboard/" webapp/src` returns nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CONTEXT_MIGRATION.md — Global Components row flips to done, and clipboard comes out of the "exist only in CLJS" gap list. The row scopes the tick to the document-level listener and names the three CLJS views that still call navigator.clipboard.writeText ungated, so the doc does not claim coverage it does not have. New Gotchas bullet covers the App.jsx mount point, the capture phase, the :8280 origin having no enforcement, and the two-Toaster overlay on catch-all routes. MIGRATION_ROADMAP.md — Track C row marked done. The Sequencing Summary is left alone on purpose: it is a plan sketch and PR #1668 is editing that block. COMPONENTS.md — useClipboardGuard entry under Hooks, including the rule that it is already called in App.jsx and must not be called from a page. CLAUDE.md — short Clipboard section next to Snackbars: never call navigator.clipboard.writeText directly, never import Mantine's CopyButton directly. DeploymentInstructions.jsx was a live violation of exactly this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
Migration Safety AnalysisNo database migrations were changed in this PR. Safe to deploy to sandbox. |
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
|
✅ Build Completed with Success, Version=1672.0.0-gcaa108b |
Contributor
|
✅ Build Completed with Success, Version=1672.0.0-g3cf2f27 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
📝 Description
disable_clipboard_copy_cutis a security control, but it was only ever enforced by the ClojureScript bundle — andClojureApp.jsxinjects/js/app.jslazily, only when the catch-all route mounts. A session that stays on React routes (/dashboard,/agents,/settings/*,/roles/*) never ran:clipboard/initialize, so the setting was silently not applied. React only hid copy buttons;grepfor a copy/cutaddEventListenerinwebapp_v2/srcreturned zero.This ports the document-level guard to the React shell and makes React the single owner: the CLJS namespace, its require and its dispatch are deleted, so there is exactly one listener set and one cooldown.
Two additional bypasses were found while verifying and are fixed here, because shipping a security control next to a known-open bypass is not a fix:
/agents/newcopied the agent deploy secret with the control on.DeploymentInstructions.jsxused a localInlineCopybuilt on Mantine'sCopyButton, ungated. The CLJS equivalent (agents/deployment.cljs:104) does gate on:gateway->clipboard-disabled?. Straight React parity bug.useUserStore.clear()had zero call sites. Sidebar logout is a soft SPA navigation, so re-logging into a different org in the same tab inherited the previous org'sdisableClipboard,isAdminandserverInfoLoaded.📣 User-facing impact
Organizations that turn off clipboard copy/cut now have it actually enforced everywhere in the app, instead of only on the pages still served by the legacy frontend. Copying the agent deploy key on the agent setup screen is also blocked when the setting is on.
🔗 Related Issue
EVL-143 — https://linear.app/hoophq/issue/EVL-143
🚀 Type of Change
📋 Changes Made
React shell (
webapp_v2/)src/utils/clipboardPolicy.js(new) — the policy: verbatim message, 2s cooldown at module scope (the analogue of the CLJSdefonceatom, so it survives remounts and StrictMode), refcounted install/uninstall, andcopyToClipboard().src/hooks/useClipboardGuard.js(new) — installs/removes the four listeners keyed onuseUserStore.disableClipboard.src/App.jsx— calls the hook once. App and not Layout:Router.jsx:531-547shows/onboarding/*(a CLJS route) and/onboarding/protection-ruleshaveProtectedRoutebut noLayout, so mounting in Layout while deleting the CLJS listeners would leave a CLJS route enforcing nothing.ProtectedRoutewas also rejected — it is a per-Route element with four early returns whose own comment documents React Router reusing/discarding the instance.src/pages/Agents/Create/DeploymentInstructions.jsx— localInlineCopyreplaced by the gated@/components/CopyButtonat all three sites (size="sm"keeps the 22px icon buttons).src/features/CommandPalette/index.jsx— theHOOP_CLIaction goes throughcopyToClipboard, and no longer swallows a rejected write. Note this action is currently unreachable (ConnectionActionsPage.jsxdefinesACTION_TYPES.HOOP_CLIbut renders noSpotlightAction, and the CLJS palette has no copy-CLI action) — zero user-visible change; it is guarded because it is the onlywriteTextcall site inwebapp_v2.src/stores/useAuthStore.js—logout()also callsuseUserStore.clear().src/components/ProtectedRoute.jsx— retries/serverinfoon navigation while it is unloaded.ClojureScript (
webapp/) — one atomic commit, shadow-cljs fails on a require of a missing namespacesrc/webapp/events/clipboard.cljs, theapp.cljs:62require and the:clipboard/initializedispatch in::gateway->set-info.:gateway->clipboard-disabled?sub (defined ingateway_info.cljs, not in the deleted namespace) and its 9 view consumers; the CodeMirrorMod-c/Mod-xkeymap inwebclient/panel.cljs, which is Wave 7 territory.Docs —
CONTEXT_MIGRATION.md(table row + gap list + gotcha),MIGRATION_ROADMAP.md(Track C row),COMPONENTS.md(hook entry),webapp_v2/CLAUDE.md(Clipboard rule).Why capture phase +
stopImmediatePropagation(one line stronger than CLJS)Per the Clipboard API spec a canceled
copy/cutevent still writes whatever a handler placed inevent.clipboardDataviasetData()— which is exactly what CodeMirror 6 does on itscontentDOM. Bubble-phasepreventDefaultalone leaves right-click → Copy working inside the/clienteditor. Blast radius verified empty: zeroonCopy/onCut/clipboardDatahandlers inwebapp_v2/src, listeners only exist while the flag is on,pasteis untouched.No analytics impact
The deleted namespace emitted no
track()/Track*call, there is no clipboard constant ingateway/analytics/events.go, and the::gateway->set-info:fxvector keeps:tracking->initialize-if-allowedand:initialize-monitoring— only the trailing third element is removed. Flagging this explicitly because the shape of the diff (a dispatch removed from the/serverinfosuccess handler) is the same shape as thehoop-exec-runbookincident in the rootCLAUDE.md.Known limits, stated rather than discovered
useClipboardGuard()stops running the control is off on every route, not just React-only ones. The test plan exercises a React route, a CLJS route under Layout, and a CLJS route without Layout./serverinfo— identical to CLJS, where:clipboard/initializeonly fired from the success handler. Failing closed was rejected: most orgs have the flag off, so a transient hiccup would kill copy app-wide behind a factually wrong "disabled by administrator" toast. The per-navigation retry restores the self-heal instead./loginusesgetPublicServerInfo(), which never callssetServerInfo, so the flag stays false. Same as CLJS — not a regression.navigator.clipboard.writeTextungated —integrations/authentication/views/advanced_tab.cljs:13(the IDP API secret),features/workflows/views/header.cljs:22,features/ai_session_analyzer/views/rule_form.cljs:189.writeTextis a separate code path that no document listener in either implementation ever observed, so these are neither regressed nor addressed here. TheCONTEXT_MIGRATION.mdrow scopes its ✅ to the document-level listener and names them; follow-up is EVL-177./clientshows two toast chromes for the same message. The surviving CodeMirror keymap dispatches the CLJS snackbar (dark box, no cooldown); the React guard shows a sonner toast. No double-toast — the keymap's:runreturnstrue, so the browser never emits acopyevent. Resolves when the webclient migrates in Wave 7.:8280) has no clipboard enforcement after this change — it serves the CLJS-onlyindex.htmland never loads the React shell. Both shipped packaging paths (Makefile:250-258andscripts/dev/build-webapp.sh) copywebapp_v2/distoverwebapp/resources, so React'sindex.htmlalways wins in every deployed artifact. Not a production regression, but test on the merged build.beforecopy/beforecutare non-standard WebKit events — inert in Chrome/Firefox, but cancelling them greys out Safari's Edit ▸ Copy. Kept verbatim; dropping them would be a silent Safari regression.app.cljs:67-69, four lines from ours — expect a trivial conflict in that block and keep both deletions.MIGRATION_ROADMAP.md's Sequencing Summary is deliberately not touched: it is a plan sketch and PR EVL-145 + EVL-161: Sessions list in React, and the backend fields it needs #1668 owns that block.🧪 Testing
Test Configuration:
beforecopycheck)Tests performed:
webapp_v2has no test runner (no vitest, zero*.test.*)npx shadow-cljs release hoop-uicompiles, 3470 files, 0 warningsHow to test
Setup
The flag is a gateway env var, not a UI toggle (
gateway/appconfig/appconfig.go:285). Test on the merged build — the raw shadow-cljs origin:8280has no enforcement by design after this PR.Open http://localhost:5173 (or the gateway at
:8009aftermake build-dev-webapp). Log in as an admin.Steps — control ON
/agents(React + Layout)Cmd+CClipboard copy/cut operations are disabled by administrator. Paste into a text editor → previous clipboard content, unchanged/agentsCmd+Cfour more times within 2s/onboarding/setup(CLJS, no Layout)Cmd+CLayout/dashboard(CLJS + Layout)Cmd+C<p>/<button>resets can shift spacing thatToast.module.cssdoes not explicitly set/client→ click inside the CodeMirror editorCmd+CMod-ckeymap) — expected, see limit 5 above/client→ right-click over selected editor text → Copymainthis silently copies — it is the bypass the capture phase closes/client→ caret in the editorCmd+V/agents/new→ Dockermainthe third one copies the agent deploy secret/login(logged out)Cmd+CSteps — supporting fixes
mainthe previous org's flags leak in./serverinfoself-heal. DevTools ▸ Network ▸ block**/api/serverinfo, hard-reload/agents, then unblock and navigate to another page. → After the navigationCmd+Cis blocked. Without the retry the control stays off for the whole session.Steps — control OFF
DISABLE_CLIPBOARD_COPY_CUT=false, restart the gateway, hard-reload. On/agents,/clientand/onboarding/setup:Cmd+C,Cmd+Xand right-click → Copy all work, no toasts. The three copy icons on/agents/neware back.document▸ Event Listeners → nocopy/cut/beforecopy/beforecutentries.Regression check
/settings/api-keys/createdand/ai-agents-identities/created— with the control OFF the copy buttons still copy; with it ON they are hidden (unchangedCopyButtonbehavior).Cmd+K) → search a connection → Open in Web Terminal still navigates to/client?role=…./rulepacks,/features/event-routing) still loads for an enterprise license and still redirects to/for a free one — this PR touchesProtectedRoute.Safari-only
beforecopycancellation). Confirms the four-event list was kept verbatim.Build gates (already run on this branch)
📸 Screenshots (if applicable)
n/a — the visible change is a toast that already exists in the legacy app, rendered with the same text and the same sonner component.
✅ Checklist
📄 Additional Notes
Reviewers, the two calls worth your attention:
App.jsxinstead ofLayout.jsxas the mount point — the ticket said Layout, but/onboarding/*is a CLJS route with no Layout, so Layout + the CLJS deletion would have been a regression on that route.stopImmediatePropagation— one line stronger than the code being ported, deliberately, because bubble-phase alone does not stop CodeMirror'ssetData()path. Step 6 in the test plan is the proof.Do not trust GitHub's "mergeable" badge on migration PRs — it is computed lazily and does not react when
mainmoves.git merge-tree --write-tree origin/main HEADis currently clean for this branch.