Skip to content

EVL-143: enforce disable_clipboard_copy_cut in the React shell - #1672

Draft
rogefm wants to merge 4 commits into
mainfrom
rogerio/evl-143-parity-document-level-clipboard-copycut-blocking-in-the
Draft

EVL-143: enforce disable_clipboard_copy_cut in the React shell#1672
rogefm wants to merge 4 commits into
mainfrom
rogerio/evl-143-parity-document-level-clipboard-copycut-blocking-in-the

Conversation

@rogefm

@rogefm rogefm commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

📝 Description

disable_clipboard_copy_cut is a security control, but it was only ever enforced by the ClojureScript bundle — and ClojureApp.jsx injects /js/app.js lazily, 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; grep for a copy/cut addEventListener in webapp_v2/src returned 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/new copied the agent deploy secret with the control on. DeploymentInstructions.jsx used a local InlineCopy built on Mantine's CopyButton, 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's disableClipboard, isAdmin and serverInfoLoaded.

📣 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

  • 🐛 Bug fix (non-breaking change which fixes an issue)
  • ✨ New feature (non-breaking change which adds functionality)
  • 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • 📚 Documentation update
  • 🎨 Style/UI update
  • ♻️ Code refactor
  • ⚡ Performance improvement
  • ✅ Test update
  • 🔧 Build configuration change
  • 🧹 Chore

📋 Changes Made

React shell (webapp_v2/)

  • src/utils/clipboardPolicy.js (new) — the policy: verbatim message, 2s cooldown at module scope (the analogue of the CLJS defonce atom, so it survives remounts and StrictMode), refcounted install/uninstall, and copyToClipboard().
  • src/hooks/useClipboardGuard.js (new) — installs/removes the four listeners keyed on useUserStore.disableClipboard.
  • src/App.jsx — calls the hook once. App and not Layout: Router.jsx:531-547 shows /onboarding/* (a CLJS route) and /onboarding/protection-rules have ProtectedRoute but no Layout, so mounting in Layout while deleting the CLJS listeners would leave a CLJS route enforcing nothing. ProtectedRoute was 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 — local InlineCopy replaced by the gated @/components/CopyButton at all three sites (size="sm" keeps the 22px icon buttons).
  • src/features/CommandPalette/index.jsx — the HOOP_CLI action goes through copyToClipboard, and no longer swallows a rejected write. Note this action is currently unreachable (ConnectionActionsPage.jsx defines ACTION_TYPES.HOOP_CLI but renders no SpotlightAction, and the CLJS palette has no copy-CLI action) — zero user-visible change; it is guarded because it is the only writeText call site in webapp_v2.
  • src/stores/useAuthStore.jslogout() also calls useUserStore.clear().
  • src/components/ProtectedRoute.jsx — retries /serverinfo on navigation while it is unloaded.

ClojureScript (webapp/) — one atomic commit, shadow-cljs fails on a require of a missing namespace

  • Deleted src/webapp/events/clipboard.cljs, the app.cljs:62 require and the :clipboard/initialize dispatch in ::gateway->set-info.
  • Untouched on purpose: the :gateway->clipboard-disabled? sub (defined in gateway_info.cljs, not in the deleted namespace) and its 9 view consumers; the CodeMirror Mod-c/Mod-x keymap in webclient/panel.cljs, which is Wave 7 territory.

DocsCONTEXT_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/cut event still writes whatever a handler placed in event.clipboardData via setData() — which is exactly what CodeMirror 6 does on its contentDOM. Bubble-phase preventDefault alone leaves right-click → Copy working inside the /client editor. Blast radius verified empty: zero onCopy/onCut/clipboardData handlers in webapp_v2/src, listeners only exist while the flag is on, paste is untouched.

No analytics impact

The deleted namespace emitted no track()/Track* call, there is no clipboard constant in gateway/analytics/events.go, and the ::gateway->set-info :fx vector keeps :tracking->initialize-if-allowed and :initialize-monitoring — only the trailing third element is removed. Flagging this explicitly because the shape of the diff (a dispatch removed from the /serverinfo success handler) is the same shape as the hoop-exec-runbook incident in the root CLAUDE.md.

Known limits, stated rather than discovered

  1. Single point of failure by design. After the CLJS deletion, if 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.
  2. Fails open on unknown /serverinfo — identical to CLJS, where :clipboard/initialize only 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.
  3. Public auth routes are not covered. /login uses getPublicServerInfo(), which never calls setServerInfo, so the flag stays false. Same as CLJS — not a regression.
  4. Three CLJS views still call navigator.clipboard.writeText ungatedintegrations/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. writeText is a separate code path that no document listener in either implementation ever observed, so these are neither regressed nor addressed here. The CONTEXT_MIGRATION.md row scopes its ✅ to the document-level listener and names them; follow-up is EVL-177.
  5. /client shows 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 :run returns true, so the browser never emits a copy event. Resolves when the webclient migrates in Wave 7.
  6. The raw shadow-cljs origin (:8280) has no clipboard enforcement after this change — it serves the CLJS-only index.html and never loads the React shell. Both shipped packaging paths (Makefile:250-258 and scripts/dev/build-webapp.sh) copy webapp_v2/dist over webapp/resources, so React's index.html always wins in every deployed artifact. Not a production regression, but test on the merged build.
  7. On catch-all routes the React toast can overlay a CLJS toast — two sonner instances from two bundles, same fixed offsets. Newly reachable because clipboard toasts moved to the React side. The real fix is dropping the CLJS Toaster at bridge endgame.
  8. beforecopy/beforecut are 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.
  9. Merge coordination. Track A2/A3 (EVL-117/EVL-118) delete requires at 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:

  • Browser(s): Chrome (primary), Safari (for the beforecopy check)
  • OS: macOS

Tests performed:

  • Unit tests pass — n/a, webapp_v2 has no test runner (no vitest, zero *.test.*)
  • Integration tests pass — npx shadow-cljs release hoop-ui compiles, 3470 files, 0 warnings
  • Manual testing completed

How 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 :8280 has no enforcement by design after this PR.

# 1. Postgres
make run-dev-postgres

# 2. Turn the control ON in your .env, then start the gateway + agent
#    DISABLE_CLIPBOARD_COPY_CUT=true
make run-dev                     # gateway :8009

# 3. Frontend — shadow-cljs needs Node 22 (re2 breaks on 25)
nvm use 22
cd webapp_v2 && npm ci && npm run dev:full   # Vite :5173 + shadow-cljs :8280

Open http://localhost:5173 (or the gateway at :8009 after make build-dev-webapp). Log in as an admin.

Steps — control ON

# Where Do Expect
1 /agents (React + Layout) Select text, Cmd+C Exactly one error toast, top-right: Clipboard copy/cut operations are disabled by administrator. Paste into a text editor → previous clipboard content, unchanged
2 /agents Cmd+C four more times within 2s Still one toast total (2s cooldown)
3 /onboarding/setup (CLJS, no Layout) Select text, Cmd+C Blocked, one toast. This is the case that proves the App-level mount point — it fails if the guard is mounted in Layout
4 /dashboard (CLJS + Layout) Cmd+C Exactly one toast, not two. Proves the CLJS dispatch is really gone rather than merely superseded. Also compare it visually against the toast from step 1: this is a React toast rendering while the CLJS Tailwind sheet is enabled, and Tailwind's preflight is unlayered, so bare <p>/<button> resets can shift spacing that Toast.module.css does not explicitly set
5 /client → click inside the CodeMirror editor Cmd+C Blocked. Shows the dark CLJS snackbar (the surviving Mod-c keymap) — expected, see limit 5 above
6 /client → right-click over selected editor text → Copy Blocked, sonner toast. On main this silently copies — it is the bypass the capture phase closes
7 /client → caret in the editor Cmd+V Paste works normally. The guard must never touch paste
8 /agents/new → Docker Look at the Docker image row and the two Environment-variables rows All three copy icons absent. On main the third one copies the agent deploy secret
9 /login (logged out) Load the page, Cmd+C Renders, no console error, copy works. Documented parity with CLJS, not a gap

Steps — supporting fixes

  1. Cross-org leak. Log out via the sidebar → log in with local auth as a user in a different org that has the control OFF. Do not reload. → Copy works with no toast, and the sidebar shows the new user's admin/non-admin nav. On main the previous org's flags leak in.
  2. /serverinfo self-heal. DevTools ▸ Network ▸ block **/api/serverinfo, hard-reload /agents, then unblock and navigate to another page. → After the navigation Cmd+C is blocked. Without the retry the control stays off for the whole session.

Steps — control OFF

  1. Set DISABLE_CLIPBOARD_COPY_CUT=false, restart the gateway, hard-reload. On /agents, /client and /onboarding/setup: Cmd+C, Cmd+X and right-click → Copy all work, no toasts. The three copy icons on /agents/new are back.
  2. DevTools ▸ Elements ▸ select document ▸ Event Listeners → no copy / cut / beforecopy / beforecut entries.
  3. Turn it back ON and reload → exactly one listener per event (proves StrictMode's double-invoked effect plus the refcount net to one, rather than leaking a duplicate).

Regression check

  • /settings/api-keys/created and /ai-agents-identities/created — with the control OFF the copy buttons still copy; with it ON they are hidden (unchanged CopyButton behavior).
  • Command palette (Cmd+K) → search a connection → Open in Web Terminal still navigates to /client?role=….
  • Any gated route (/rulepacks, /features/event-routing) still loads for an enterprise license and still redirects to / for a free one — this PR touches ProtectedRoute.

Safari-only

  1. With the control ON, open Safari's Edit menu → Copy is greyed out (the non-standard beforecopy cancellation). Confirms the four-event list was kept verbatim.

Build gates (already run on this branch)

cd webapp_v2 && npm run build     # ✅ built in 2.03s
cd webapp_v2 && npm run lint      # 15 errors, all pre-existing on main:
                                  # vite.config.js, CommandPaletteRoot.jsx,
                                  # Auth/Login/index.jsx, EventRouting/Form/index.jsx
                                  # — none in a file this PR touches
nvm use 22 && cd webapp && npx shadow-cljs release hoop-ui   # ✅ 3470 files, 0 warnings
grep -rn "clipboard/" webapp/src  # ✅ zero matches

Heads-up for whoever pulls this: webapp_v2/node_modules may be stale on your machine (lockfile wants lucide-react@1.27.0; an older 0.577.0 install makes npm run build fail on ShieldCogCorner in Sidebar/ConfigStatus/steps.js, which is unrelated to this PR). npm ci fixes it.

📸 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

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • My changes generate no new warnings
  • New and existing unit tests pass locally with my changes
  • I have checked my code and corrected any misspellings

📄 Additional Notes

Reviewers, the two calls worth your attention:

  1. App.jsx instead of Layout.jsx as 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.
  2. Capture phase + stopImmediatePropagation — one line stronger than the code being ported, deliberately, because bubble-phase alone does not stop CodeMirror's setData() 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 main moves. git merge-tree --write-tree origin/main HEAD is currently clean for this branch.

rogefm and others added 3 commits August 4, 2026 13:53
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>
@rogefm rogefm added the patch Bumps the patch version on release (bug fixes) label Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Migration Safety Analysis

No database migrations were changed in this PR. Safe to deploy to sandbox.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sandromello

Copy link
Copy Markdown
Contributor

✅ Build Completed with Success, Version=1672.0.0-gcaa108b

@sandromello

Copy link
Copy Markdown
Contributor

✅ Build Completed with Success, Version=1672.0.0-g3cf2f27

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

patch Bumps the patch version on release (bug fixes)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants