keyBlocklist.ts 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. /**
  2. * Key combos that cross app boundaries or terminate processes. Gated behind
  3. * the `systemKeyCombos` grant flag. When that flag is off, the `key` tool
  4. * rejects these and returns a tool error telling the model to request the
  5. * flag; all other combos work normally.
  6. *
  7. * Matching is canonicalized: every modifier alias the Rust executor accepts
  8. * collapses to one canonical name. Without this, `command+q` / `meta+q` /
  9. * `cmd+alt+escape` bypass the gate — see keyBlocklist.test.ts for the three
  10. * bypass forms and the Rust parity check that catches future alias drift.
  11. */
  12. /**
  13. * Every modifier alias enigo_wrap.rs accepts (two copies: :351-359, :564-572),
  14. * mapped to one canonical per Key:: variant. Left/right variants collapse —
  15. * the blocklist doesn't distinguish which Ctrl.
  16. *
  17. * Canonical names are Rust's own variant names lowercased. Blocklist entries
  18. * below use ONLY these. "meta" reads odd for Cmd+Q but it's honest: Rust
  19. * sends Key::Meta, which is Cmd on darwin and Win on win32.
  20. */
  21. const CANONICAL_MODIFIER: Readonly<Record<string, string>> = {
  22. // Key::Meta — "meta"|"super"|"command"|"cmd"|"windows"|"win"
  23. meta: "meta",
  24. super: "meta",
  25. command: "meta",
  26. cmd: "meta",
  27. windows: "meta",
  28. win: "meta",
  29. // Key::Control + LControl + RControl
  30. ctrl: "ctrl",
  31. control: "ctrl",
  32. lctrl: "ctrl",
  33. lcontrol: "ctrl",
  34. rctrl: "ctrl",
  35. rcontrol: "ctrl",
  36. // Key::Shift + LShift + RShift
  37. shift: "shift",
  38. lshift: "shift",
  39. rshift: "shift",
  40. // Key::Alt and Key::Option — distinct Rust variants but same keycode on
  41. // darwin (kVK_Option). Collapse: cmd+alt+escape and cmd+option+escape
  42. // both Force Quit.
  43. alt: "alt",
  44. option: "alt",
  45. };
  46. /** Sort order for canonicals. ctrl < alt < shift < meta. */
  47. const MODIFIER_ORDER = ["ctrl", "alt", "shift", "meta"];
  48. /**
  49. * Canonical-form entries only. Every modifier must be a CANONICAL_MODIFIER
  50. * *value* (not key), modifiers must be in MODIFIER_ORDER, non-modifier last.
  51. * The self-consistency test enforces this.
  52. */
  53. const BLOCKED_DARWIN = new Set([
  54. "meta+q", // Cmd+Q — quit frontmost app
  55. "shift+meta+q", // Cmd+Shift+Q — log out
  56. "alt+meta+escape", // Cmd+Option+Esc — Force Quit dialog
  57. "meta+tab", // Cmd+Tab — app switcher
  58. "meta+space", // Cmd+Space — Spotlight
  59. "ctrl+meta+q", // Ctrl+Cmd+Q — lock screen
  60. ]);
  61. const BLOCKED_WIN32 = new Set([
  62. "ctrl+alt+delete", // Secure Attention Sequence
  63. "alt+f4", // close window
  64. "alt+tab", // window switcher
  65. "meta+l", // Win+L — lock
  66. "meta+d", // Win+D — show desktop
  67. ]);
  68. /**
  69. * Partition into sorted-canonical modifiers and non-modifier keys.
  70. * Shared by normalizeKeySequence (join for display) and isSystemKeyCombo
  71. * (check mods+each-key to catch the cmd+q+a suffix bypass).
  72. */
  73. function partitionKeys(seq: string): { mods: string[]; keys: string[] } {
  74. const parts = seq
  75. .toLowerCase()
  76. .split("+")
  77. .map((p) => p.trim())
  78. .filter(Boolean);
  79. const mods: string[] = [];
  80. const keys: string[] = [];
  81. for (const p of parts) {
  82. const canonical = CANONICAL_MODIFIER[p];
  83. if (canonical !== undefined) {
  84. mods.push(canonical);
  85. } else {
  86. keys.push(p);
  87. }
  88. }
  89. // Dedupe: "cmd+command+q" → "meta+q", not "meta+meta+q".
  90. const uniqueMods = [...new Set(mods)];
  91. uniqueMods.sort(
  92. (a, b) => MODIFIER_ORDER.indexOf(a) - MODIFIER_ORDER.indexOf(b),
  93. );
  94. return { mods: uniqueMods, keys };
  95. }
  96. /**
  97. * Normalize "Cmd + Shift + Q" → "shift+meta+q": lowercase, trim, alias →
  98. * canonical, dedupe, sort modifiers, non-modifiers last.
  99. */
  100. export function normalizeKeySequence(seq: string): string {
  101. const { mods, keys } = partitionKeys(seq);
  102. return [...mods, ...keys].join("+");
  103. }
  104. /**
  105. * True if the sequence would fire a blocked OS shortcut.
  106. *
  107. * Checks mods + EACH non-modifier key individually, not just the full
  108. * joined string. `cmd+q+a` → Rust presses Cmd, then Q (Cmd+Q fires here),
  109. * then A. Exact-match against "meta+q+a" misses; checking "meta+q" and
  110. * "meta+a" separately catches the Q.
  111. *
  112. * Modifiers-only sequences ("cmd+shift") are checked as-is — no key to
  113. * pair with, and no blocklist entry is modifier-only, so this is a no-op
  114. * that falls through to false. Covers the click-modifier case where
  115. * `left_click(text="cmd")` is legitimate.
  116. */
  117. export function isSystemKeyCombo(
  118. seq: string,
  119. platform: "darwin" | "win32",
  120. ): boolean {
  121. const blocklist = platform === "darwin" ? BLOCKED_DARWIN : BLOCKED_WIN32;
  122. const { mods, keys } = partitionKeys(seq);
  123. const prefix = mods.length > 0 ? mods.join("+") + "+" : "";
  124. // No non-modifier keys (e.g. "cmd+shift" as click-modifiers) — check the
  125. // whole thing. Never matches (no blocklist entry is modifier-only) but
  126. // keeps the contract simple: every call reaches a .has().
  127. if (keys.length === 0) {
  128. return blocklist.has(mods.join("+"));
  129. }
  130. // mods + each key. Any hit blocks the whole sequence.
  131. for (const key of keys) {
  132. if (blocklist.has(prefix + key)) {
  133. return true;
  134. }
  135. }
  136. return false;
  137. }
  138. export const _test = {
  139. CANONICAL_MODIFIER,
  140. BLOCKED_DARWIN,
  141. BLOCKED_WIN32,
  142. MODIFIER_ORDER,
  143. };