aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-21 23:12:02 -0500
committerCraig Jennings <c@cjennings.net>2026-07-21 23:12:02 -0500
commit32017dc068d7579e789c5e753ae35f6f10c3d5b3 (patch)
treeb6de07ba352e88dc3c7c60ed9d7a5351297fc34e
parentdeedf75cead6e8860f66f5e9e5fe66b8ed92f083 (diff)
downloaddotemacs-main.tar.gz
dotemacs-main.zip
fix: make the Full Agenda frame's default-deny actually denyHEADmain
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.el42
-rw-r--r--tests/test-org-agenda-frame.el28
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