Skip to content

Commit 9e7c46b

Browse files
committed
clipboard kitten: Allow using a password to avoid repeated confirmation prompts when accessing the clipboard
Fixes #8789
1 parent 2ac3565 commit 9e7c46b

File tree

7 files changed

+218
-20
lines changed

7 files changed

+218
-20
lines changed

docs/changelog.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ Detailed list of changes
111111

112112
- A new :ref:`protocol extension <mouse_leave_window>` to notify terminal programs that have turned on SGR Pixel mouse reporting when the mouse leaves the window (:disc:`8808`)
113113

114+
- clipboard kitten: Can now optionally take a password to avoid repeated
115+
permission prompts when accessing the clipboard. Based on a
116+
:ref:`protocol extension <clipboard_repeated_permission>`. (:iss:`8789`)
117+
114118
- A new :option:`launch --hold-after-ssh` to not close a launched window
115119
that connects directly to a remote host because of
116120
:option:`launch --cwd`:code:`=current` when the connection ends (:pull:`8807`)

docs/clipboard.rst

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,29 @@ the data, but create multiple references to it in the system clipboard. Alias
138138
packets can be sent anytime after the initial write packet and before the end
139139
of data packet.
140140

141+
.. _clipboard_repeated_permission:
142+
143+
Avoiding repeated permission prompts
144+
--------------------------------------
145+
146+
.. versionadded:: using a password to avoid repeated confirmations was added in version 0.43.0
147+
148+
If a program like an editor wants to make use of the system clipboard, by
149+
default, the user is prompted on every read request. This can become quite
150+
fatiguing. To avoid this situation, this protocol allows sending a password
151+
and human friendly name with ``type=write`` and ``type=read`` requests. The
152+
terminal can then ask the user to allow all future requests using that
153+
password. If the user agrees, future requests on the same tty will be
154+
automatically allowed by the terminal. The editor or other program using
155+
this facility should ideally use a password randomnly generated at startup,
156+
such as a UUID4. However, terminals may implement permanent/stored passwords.
157+
Users can then configure terminal programs they trust to use these password.
158+
159+
The password and the human name are encoded using the ``pw`` and ``name`` keys
160+
in the metadata. The values are UTF-8 strings that are base64 encoded.
161+
Specifying a password without a human friendly name is equivalent to not
162+
specifying a password and the terminal must treat the request as though
163+
it had no password.
141164

142165
Support for terminal multiplexers
143166
------------------------------------

kittens/clipboard/main.go

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
33
package clipboard
44

55
import (
6+
"fmt"
7+
"io"
68
"os"
9+
"strconv"
10+
"strings"
11+
"unicode"
712

813
"github.com/kovidgoyal/kitty/tools/cli"
914
)
@@ -20,10 +25,47 @@ func run_mime_loop(opts *Options, args []string) (err error) {
2025
}
2126

2227
func clipboard_main(cmd *cli.Command, opts *Options, args []string) (rc int, err error) {
28+
if opts.Password != "" {
29+
if opts.HumanName == "" {
30+
return 1, fmt.Errorf("must specify --human-name when using a password")
31+
}
32+
ptype, val, found := strings.Cut(opts.Password, ":")
33+
if !found {
34+
return 1, fmt.Errorf("invalid password: %#v no password type specified", opts.Password)
35+
}
36+
switch ptype {
37+
case "text":
38+
opts.Password = val
39+
case "fd":
40+
if fd, err := strconv.Atoi(val); err == nil {
41+
if f := os.NewFile(uintptr(fd), "password-fd"); f == nil {
42+
return 1, fmt.Errorf("invalid file descriptor: %d", fd)
43+
} else {
44+
data, err := io.ReadAll(f)
45+
f.Close()
46+
if err != nil {
47+
return 1, fmt.Errorf("failed to read from file descriptor: %d with error: %w", fd, err)
48+
}
49+
opts.Password = strings.TrimRightFunc(string(data), unicode.IsSpace)
50+
}
51+
52+
} else {
53+
return 1, fmt.Errorf("not a valid file descriptor number: %#v", val)
54+
}
55+
case "file":
56+
if data, err := os.ReadFile(val); err == nil {
57+
opts.Password = strings.TrimRightFunc(string(data), unicode.IsSpace)
58+
} else {
59+
return 1, fmt.Errorf("failed to read from file: %#v with error: %w", val, err)
60+
}
61+
}
62+
}
2363
if len(args) > 0 {
2464
return 0, run_mime_loop(opts, args)
2565
}
26-
66+
if opts.Password != "" || opts.HumanName != "" {
67+
return 1, fmt.Errorf("cannot use --human-name or --password in filter mode")
68+
}
2769
return 0, run_plain_text_loop(opts)
2870
}
2971

kittens/clipboard/main.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,18 @@
4444
type=bool-set
4545
Wait till the copy to clipboard is complete before exiting. Useful if running
4646
the kitten in a dedicated, ephemeral window. Only needed in filter mode.
47+
48+
49+
--password
50+
A password to use when accessing the clipboard. If the user chooses to accept the password
51+
future invocations of the kitten will not have a permission prompt in this tty session. Does not
52+
work in filter mode. Must be of the form: text:actual-password or fd:integer (a file descriptor
53+
number to read the password from) or file:path-to-file (a file from which to read the password).
54+
Note that you must also specify a human friendly name using the :option:`--human-name` flag.
55+
56+
57+
--human-name
58+
A human friendly name to show the user when asking for permission to access the clipboard.
4759
'''.format
4860
help_text = '''\
4961
Read or write to the system clipboard.

kittens/clipboard/read.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,9 +329,14 @@ func run_get_loop(opts *Options, args []string) (err error) {
329329
if opts.UsePrimary {
330330
basic_metadata["loc"] = "primary"
331331
}
332-
333332
lp.OnInitialize = func() (string, error) {
334333
lp.QueueWriteString(encode(basic_metadata, "."))
334+
if opts.Password != "" {
335+
basic_metadata["pw"] = base64.StdEncoding.EncodeToString(utils.UnsafeStringToBytes(opts.Password))
336+
}
337+
if opts.HumanName != "" {
338+
basic_metadata["name"] = base64.StdEncoding.EncodeToString(utils.UnsafeStringToBytes(opts.HumanName))
339+
}
335340
return "", nil
336341
}
337342

kittens/clipboard/write.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package clipboard
44

55
import (
6+
"encoding/base64"
67
"errors"
78
"fmt"
89
"io"
@@ -80,6 +81,14 @@ func write_loop(inputs []*Input, opts *Options) (err error) {
8081
if mime != "" {
8182
ans["mime"] = mime
8283
}
84+
if ptype == "write" {
85+
if opts.Password != "" {
86+
ans["pw"] = base64.StdEncoding.EncodeToString(utils.UnsafeStringToBytes(opts.Password))
87+
}
88+
if opts.HumanName != "" {
89+
ans["name"] = base64.StdEncoding.EncodeToString(utils.UnsafeStringToBytes(opts.HumanName))
90+
}
91+
}
8392
return ans
8493
}
8594

kitty/clipboard.py

Lines changed: 121 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
get_options,
2323
set_clipboard_data_types,
2424
)
25+
from .typing_compat import WindowType
2526
from .utils import log_error
2627

2728
READ_RESPONSE_CHUNK_SIZE = 4096
@@ -200,7 +201,7 @@ def encode_mime(x: str) -> str:
200201

201202

202203
def decode_metadata_value(k: str, x: str) -> str:
203-
if k == 'mime':
204+
if k in ('mime', 'name', 'pw'):
204205
import base64
205206
x = base64.standard_b64decode(x).decode('utf-8')
206207
return x
@@ -211,6 +212,8 @@ class ReadRequest(NamedTuple):
211212
mime_types: tuple[str, ...] = ('text/plain',)
212213
id: str = ''
213214
protocol_type: ProtocolType = ProtocolType.osc_52
215+
human_name: str = ''
216+
password: str = ''
214217

215218
def encode_response(self, status: str = 'DATA', mime: str = '', payload: bytes | memoryview = b'') -> bytes:
216219
ans = f'{self.protocol_type.value};type=read:status={status}'
@@ -242,9 +245,11 @@ class WriteRequest:
242245

243246
def __init__(
244247
self, is_primary_selection: bool = False, protocol_type: ProtocolType = ProtocolType.osc_52, id: str = '',
245-
rollover_size: int = 16 * 1024 * 1024, max_size: int = -1,
248+
rollover_size: int = 16 * 1024 * 1024, max_size: int = -1, human_name: str = '', password: str = '',
246249
) -> None:
247250
self.decoder = StreamingBase64Decoder()
251+
self.human_name = human_name
252+
self.password = password
248253
self.id = id
249254
self.is_primary_selection = is_primary_selection
250255
self.protocol_type = protocol_type
@@ -255,6 +260,8 @@ def __init__(
255260
self.max_size = (get_options().clipboard_max_size * 1024 * 1024) if max_size < 0 else max_size
256261
self.aliases: dict[str, str] = {}
257262
self.committed = False
263+
self.permission_pending = True
264+
self.commit_pending = False
258265

259266
def encode_response(self, status: str = 'OK') -> bytes:
260267
ans = f'{self.protocol_type.value};type=write:status={status}'
@@ -266,7 +273,11 @@ def encode_response(self, status: str = 'OK') -> bytes:
266273
def commit(self) -> None:
267274
if self.committed:
268275
return
276+
if self.permission_pending:
277+
self.commit_pending = True
278+
return
269279
self.committed = True
280+
self.commit_pending = False
270281
cp = get_boss().primary_selection if self.is_primary_selection else get_boss().clipboard
271282
if cp.enabled:
272283
for alias, src in self.aliases.items():
@@ -316,13 +327,21 @@ def data_for(self, mime: str = 'text/plain', offset: int = 0, size: int = -1) ->
316327
return self.tempfile.read(start+offset, size)
317328

318329

330+
class GrantedPermission:
331+
332+
def __init__(self, read: bool = False, write: bool = False):
333+
self.read, self.write = read, write
334+
self.write_ban = self.read_ban = False
335+
336+
319337
class ClipboardRequestManager:
320338

321339
def __init__(self, window_id: int) -> None:
322340
self.window_id = window_id
323341
self.currently_asking_permission_for: ReadRequest | None = None
324342
self.in_flight_write_request: WriteRequest | None = None
325343
self.osc52_in_flight_write_requests: dict[ClipboardType, WriteRequest] = {}
344+
self.granted_passwords: dict[str, GrantedPermission] = {}
326345

327346
def parse_osc_5522(self, data: memoryview) -> None:
328347
import base64
@@ -349,13 +368,15 @@ def parse_osc_5522(self, data: memoryview) -> None:
349368
rr = ReadRequest(
350369
is_primary_selection=m.get('loc', '') == 'primary',
351370
mime_types=tuple(payload.decode('utf-8').split()),
352-
protocol_type=ProtocolType.osc_5522, id=sanitize_id(m.get('id', ''))
371+
protocol_type=ProtocolType.osc_5522, id=sanitize_id(m.get('id', '')),
372+
human_name=m.get('name', ''), password=m.get('pw', ''),
353373
)
354374
self.handle_read_request(rr)
355375
elif typ == 'write':
356376
self.in_flight_write_request = WriteRequest(
357377
is_primary_selection=m.get('loc', '') == 'primary',
358-
protocol_type=ProtocolType.osc_5522, id=sanitize_id(m.get('id', ''))
378+
protocol_type=ProtocolType.osc_5522, id=sanitize_id(m.get('id', '')),
379+
human_name=m.get('name', ''), password=m.get('pw', ''),
359380
)
360381
self.handle_write_request(self.in_flight_write_request)
361382
elif typ == 'walias':
@@ -385,11 +406,17 @@ def parse_osc_5522(self, data: memoryview) -> None:
385406
self.in_flight_write_request = None
386407
raise
387408
else:
388-
wr.flush_base64_data()
389-
wr.commit()
390-
self.in_flight_write_request = None
391-
if w is not None:
392-
w.screen.send_escape_code_to_child(ESC_OSC, wr.encode_response(status='DONE'))
409+
self.commit_write_request(wr)
410+
411+
def commit_write_request(self, wr: WriteRequest, needs_flush: bool = True) -> None:
412+
if needs_flush:
413+
wr.flush_base64_data()
414+
wr.commit()
415+
if wr.committed:
416+
self.in_flight_write_request = None
417+
w = get_boss().window_id_map.get(self.window_id)
418+
if w is not None:
419+
w.screen.send_escape_code_to_child(ESC_OSC, wr.encode_response(status='DONE'))
393420

394421
def parse_osc_52(self, data: memoryview, is_partial: bool = False) -> None:
395422
idx = find_in_memoryview(data, ord(b';'))
@@ -423,15 +450,78 @@ def handle_write_request(self, wr: WriteRequest) -> None:
423450
self.fulfill_write_request(wr, allowed)
424451

425452
def fulfill_write_request(self, wr: WriteRequest, allowed: bool = True) -> None:
453+
wr.permission_pending = not allowed
426454
if wr.protocol_type is ProtocolType.osc_52:
427455
self.fulfill_legacy_write_request(wr, allowed)
428456
return
429-
w = get_boss().window_id_map.get(self.window_id)
430457
cp = get_boss().primary_selection if wr.is_primary_selection else get_boss().clipboard
431-
if not allowed or not cp.enabled:
458+
w = get_boss().window_id_map.get(self.window_id)
459+
if w is None:
432460
self.in_flight_write_request = None
433-
if w is not None:
434-
w.screen.send_escape_code_to_child(ESC_OSC, wr.encode_response(status='EPERM' if not allowed else 'ENOSYS'))
461+
return
462+
if not cp.enabled:
463+
self.in_flight_write_request = None
464+
w.screen.send_escape_code_to_child(ESC_OSC, wr.encode_response(status='ENOSYS'))
465+
return
466+
if not allowed:
467+
if wr.password and wr.human_name:
468+
if self.password_is_allowed_already(wr.password, for_write=True):
469+
wr.permission_pending = False
470+
else:
471+
wid = w.id
472+
def callback(granted: bool) -> None:
473+
if wr is not self.in_flight_write_request:
474+
return
475+
if granted:
476+
wr.permission_pending = False
477+
if wr.commit_pending:
478+
self.commit_write_request(wr, needs_flush=False)
479+
else:
480+
w = get_boss().window_id_map.get(wid)
481+
if w is not None:
482+
w.screen.send_escape_code_to_child(ESC_OSC, wr.encode_response(status='EPERM'))
483+
self.in_flight_write_request = None
484+
485+
self.request_permission(w, wr.human_name, wr.password, callback, for_write=True)
486+
else:
487+
self.in_flight_write_request = None
488+
w.screen.send_escape_code_to_child(ESC_OSC, wr.encode_response(status='EPERM'))
489+
490+
def request_permission(self, window: WindowType, human_name: str, password: str, callback: Callable[[bool], None], for_write: bool = False) -> None:
491+
if (gp := self.granted_passwords.get(password)) and (gp.write_ban if for_write else gp.read_ban):
492+
callback(False)
493+
return
494+
495+
def cb(q: str) -> None:
496+
p = self.granted_passwords.get(password)
497+
if p is None:
498+
p = self.granted_passwords[password] = GrantedPermission()
499+
callback(q in ('a', 'w'))
500+
match q:
501+
case 'w':
502+
if for_write:
503+
p.write = True
504+
else:
505+
p.read = True
506+
case 'b':
507+
if for_write:
508+
p.write = False
509+
p.write_ban = True
510+
else:
511+
p.read = False
512+
p.read_ban = True
513+
if for_write:
514+
msg = _('The program {0} running in this window wants to write to the system clipboard.')
515+
else:
516+
msg = _('The program {0} running in this window wants to read from the system clipboard.')
517+
msg += '\n\n' + ('If you choose "Always" similar requests from this program will be automatically allowed for the rest of this session.')
518+
msg += '\n\n' + ('If you choose "Ban" similar requests from this program will be automatically dis-allowed for the rest of this session.')
519+
from kittens.tui.operations import styled
520+
get_boss().choose(msg.format(styled(human_name, fg='yellow')), cb, 'a;green:Allow', 'w;yellow:Always', 'd;red:Deny', 'b;red:Ban',
521+
default='d', window=window, title=_('A program wants to access the clipboard'))
522+
523+
def password_is_allowed_already(self, password: str, for_write: bool = False) -> bool:
524+
return (q := self.granted_passwords.get(password)) is not None and (q.write if for_write else q.read)
435525

436526
def fulfill_legacy_write_request(self, wr: WriteRequest, allowed: bool = True) -> None:
437527
cp = get_boss().primary_selection if wr.is_primary_selection else get_boss().clipboard
@@ -517,11 +607,24 @@ def ask_to_read_clipboard(self, rr: ReadRequest) -> None:
517607
w = get_boss().window_id_map.get(self.window_id)
518608
if w is not None:
519609
self.currently_asking_permission_for = rr
520-
get_boss().confirm(_(
521-
'A program running in this window wants to read from the system clipboard.'
522-
' Allow it to do so, once?'),
523-
self.handle_clipboard_confirmation, window=w,
524-
)
610+
if rr.password and rr.human_name:
611+
if self.password_is_allowed_already(rr.password):
612+
self.handle_clipboard_confirmation(True)
613+
return
614+
if (p := self.granted_passwords.get(rr.password)) and p.read_ban:
615+
self.handle_clipboard_confirmation(False)
616+
return
617+
self.request_permission(w, rr.human_name, rr.password, self.handle_clipboard_confirmation)
618+
else:
619+
if rr.human_name:
620+
msg = _(
621+
'The program {} running in this window wants to read from the system clipboard.'
622+
' Allow it to do so, once?').format(rr.human_name)
623+
else:
624+
msg = _(
625+
'A program running in this window wants to read from the system clipboard.'
626+
' Allow it to do so, once?')
627+
get_boss().confirm(msg, self.handle_clipboard_confirmation, window=w)
525628

526629
def handle_clipboard_confirmation(self, confirmed: bool) -> None:
527630
rr = self.currently_asking_permission_for

0 commit comments

Comments
 (0)