diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-21 23:12:02 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-21 23:12:02 -0500 |
| commit | 32017dc068d7579e789c5e753ae35f6f10c3d5b3 (patch) | |
| tree | b6de07ba352e88dc3c7c60ed9d7a5351297fc34e | |
| parent | deedf75cead6e8860f66f5e9e5fe66b8ed92f083 (diff) | |
| download | dotemacs-main.tar.gz dotemacs-main.zip | |
The frame's read-only policy leaned on a [t] catch-all, but a keymap's default binding never shadows an explicit binding in a lower-priority map. So every key org-agenda-mode-map binds sailed through the catch-all and could mutate source files from the "read-only" frame: t (todo), I (clock-in), k (capture), z (add-note), s (save-all), the C-c schedule/deadline/clock mutators, C-x C-s.
Walk org-agenda-mode-map recursively on load and explicitly deny every sequence not on the frame's allowlist, so the catch-all's intent holds. Navigation, engage, refresh, d/w, and C-c C-o still work, and M-x is unaffected.
| -rw-r--r-- | modules/org-agenda-frame.el | 42 | ||||
| -rw-r--r-- | tests/test-org-agenda-frame.el | 28 |
2 files changed, 70 insertions, 0 deletions
diff --git a/modules/org-agenda-frame.el b/modules/org-agenda-frame.el index 35e77912..b783e358 100644 --- a/modules/org-agenda-frame.el +++ b/modules/org-agenda-frame.el @@ -31,6 +31,7 @@ ;; `with-eval-after-load' so a batch test-load runs no org-agenda side effects. (defvar org-agenda-custom-commands) (defvar org-agenda-finalize-hook) +(defvar org-agenda-mode-map) (defvar org-agenda-sticky) (defvar org-agenda-window-setup) (declare-function cj/build-org-agenda-list "org-agenda-config" (&optional force-rebuild)) @@ -318,6 +319,47 @@ of contract." :lighter " AgendaFrame" :keymap cj/agenda-frame-mode-map) +(defun cj/--agenda-frame-shadow-mutations (&optional source-map prefix) + "Deny every SOURCE-MAP key sequence not on the frame map's allowlist. +Walk SOURCE-MAP (default `org-agenda-mode-map') recursively. For each +sequence it binds to a command, if `cj/agenda-frame-mode-map' doesn't already +bind that sequence to a command or manage it as a prefix, add an explicit +read-only deny. + +This closes the default-deny hole: a keymap's `[t]' default never shadows an +explicit binding in a lower-priority map, so a single `[t]' catch-all denies +only keys that are unbound everywhere. Every key `org-agenda-mode-map' binds +\(t, I, k, z, s, ., the C-c mutators, C-x C-s, ...) would otherwise sail +through the catch-all and mutate source files from the read-only frame. +Explicitly denying each non-allowlisted sequence makes the catch-all's intent +actually hold. + +PREFIX is the accumulated key vector during recursion (internal). Idempotent: +re-running rebinds the same denials. Runs from `with-eval-after-load' once +`org-agenda-mode-map' exists." + (let ((source (or source-map org-agenda-mode-map)) + (prefix (or prefix []))) + (map-keymap + (lambda (event binding) + (unless (or (eq event t) (eq event 'menu-bar) (eq event 'remap) + (consp event)) + (let ((seq (vconcat prefix (vector event)))) + (cond + ((keymapp binding) + (cj/--agenda-frame-shadow-mutations binding seq)) + ((commandp binding) + (let ((ours (lookup-key cj/agenda-frame-mode-map seq))) + ;; A command we allowlisted or a prefix we manage: leave it. + ;; Anything else (only the `[t]' default, or unbound under a + ;; shared prefix) escapes to org's command -- deny it here. + (unless (or (commandp ours) (keymapp ours)) + (define-key cj/agenda-frame-mode-map seq + #'cj/--agenda-frame-denied-readonly)))))))) + source))) + +(with-eval-after-load 'org-agenda + (cj/--agenda-frame-shadow-mutations)) + (defun cj/--agenda-frame-maybe-enable-mode () "Re-enable `cj/agenda-frame-mode' after an agenda build in the agenda frame. Added to `org-agenda-finalize-hook'. `org-agenda-redo' rebuilds through diff --git a/tests/test-org-agenda-frame.el b/tests/test-org-agenda-frame.el index 92dd5ddc..fd8bd839 100644 --- a/tests/test-org-agenda-frame.el +++ b/tests/test-org-agenda-frame.el @@ -315,6 +315,34 @@ d shrinks the frame to the current day, w restores the seven-day span." (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "w")) 'cj/--agenda-frame-week-view))) +(ert-deftest test-org-agenda-frame-shadow-denies-org-mutations-preserves-allowlist () + "Boundary: with the frame mode active over `org-agenda-mode-map', mutating +org-agenda keys are denied and the allowlist still works. +The `[t]' default cannot shadow org-agenda-mode-map's explicit bindings, so +the shadow walk must add explicit denies for every non-allowlisted key -- +single keys (t/I/k/z/s/.), the C-c mutators (schedule/deadline/clock), and +C-x (save-all) -- while leaving the allowlist (navigation, engage, refresh, +d/w, C-c C-o) intact." + (require 'org-agenda) + (cj/--agenda-frame-shadow-mutations) + (with-temp-buffer + (use-local-map org-agenda-mode-map) + (cj/agenda-frame-mode 1) + ;; escaping mutators are now explicitly denied (not org's commands) + (dolist (chord '("t" "I" "k" "z" "s" "." "C-c C-s" "C-c C-d" + "C-c C-x C-i" "C-x C-s")) + (should (eq (key-binding (kbd chord)) + 'cj/--agenda-frame-denied-readonly))) + ;; the allowlist survives the walk + (should (eq (key-binding (kbd "n")) 'org-agenda-next-line)) + (should (eq (key-binding (kbd "p")) 'org-agenda-previous-line)) + (should (eq (key-binding (kbd "RET")) 'cj/--agenda-frame-engage-open)) + (should (eq (key-binding (kbd "g")) 'cj/--agenda-frame-safe-redo)) + (should (eq (key-binding (kbd "d")) 'cj/--agenda-frame-day-view)) + (should (eq (key-binding (kbd "w")) 'cj/--agenda-frame-week-view)) + (should (eq (key-binding (kbd "C-c C-o")) 'cj/--agenda-frame-open-link)) + (should (eq (key-binding (kbd "q")) 'cj/--agenda-frame-close)))) + (ert-deftest test-org-agenda-frame-map-frame-controls-bound () "Normal: the frame's own controls work from inside the frame. S-<f8> must close/toggle and C-M-<f8> must force-rescan; unbound, the |
