terminal: exempt Kitty clipboard listing reads from permission prompts

A Kitty clipboard protocol (OSC 5522) read that only requests the
targets type ('.') is now served without a permission prompt and never
consults (or consumes) session password grants. 

The spec requires this so that a client listing the available data types 
before reading one doesn't present the user with a double permission prompt.
This commit is contained in:
Mitchell Hashimoto
2026-08-24 13:24:34 -07:00
parent 89d17b378e
commit 928c7f0e79
7 changed files with 114 additions and 13 deletions

View File

@@ -757,6 +757,11 @@ struct GhosttyClipboardRead {
* True if the terminal already holds a session grant for this request
* (kitty clipboard protocol passwords). The embedder should skip any
* permission prompt and serve the read.
*
* Always false when mimes_len is zero: such a request is served
* without a prompt (see the callback docs), so the terminal never
* consults grants for it and a one-time password is preserved for
* the follow-up data read.
*/
bool granted;
@@ -792,7 +797,9 @@ struct GhosttyClipboardRead {
* state; a reply that sets `remember` records a session grant so later
* requests with the same password arrive with `granted` set. Kitty itself
* serves a request for only the targets listing (`list` with no `mimes`)
* without prompting.
* without prompting, and embedders are expected to do the same; the
* terminal never consults grants for such requests (`granted` is false
* and one-time passwords are not consumed).
*
* Installing this callback also enables Kitty paste events (mode 5522):
* ghostty_terminal_paste() sends the program an event instead of the text,

View File

@@ -5931,10 +5931,13 @@ pub fn completeClipboardRequest(
// If we need confirmation we return an error without
// consuming the request state; the apprt keeps it alive
// for the confirmation flow. A session grant carried by
// the request skips the prompt.
// the request skips the prompt, and a request with no
// data types is exempt from prompting entirely; see
// readPromptExempt.
if (self.config.clipboard_read == .ask and
!complete.confirmed and
!kitty.granted)
!kitty.granted and
!terminal.kitty.clipboard.readPromptExempt(kitty.mimes.len))
{
return error.UnauthorizedPaste;
}

View File

@@ -173,6 +173,11 @@ pub const Read = struct {
/// True if the terminal already holds a session grant for this
/// request (kitty clipboard protocol passwords). The embedder should
/// skip any permission prompt and serve the read.
///
/// Always false when mimes is empty: such a request is served
/// without a prompt (kitty's targets-listing exemption), so the
/// terminal never consults grants for it and a one-time password
/// is preserved for the follow-up data read.
granted: bool,
/// True if the program supplied a session password, so the embedder

View File

@@ -44,6 +44,7 @@ pub const Terminator = oscpkg.Terminator;
pub const Metadata = command.Metadata;
pub const Payload = command.Payload;
pub const readPromptExempt = command.readPromptExempt;
pub const max_id_len = command.max_id_len;
pub const max_pw_len = command.max_pw_len;
pub const max_mime_len = command.max_mime_len;

View File

@@ -226,6 +226,12 @@ pub const Payload = struct {
}
};
/// Whether a read request with this many data MIME types (the targets
/// type '.' excluded) is exempt from the user permission prompt.
pub fn readPromptExempt(data_mimes: usize) bool {
return data_mimes == 0;
}
test "metadata: empty is dropped" {
const testing = std.testing;
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
@@ -369,6 +375,16 @@ test "metadata: empty name" {
try testing.expectEqual(@as(usize, 0), meta.name.len);
}
test "read prompt exemption: only requests without data types" {
const testing = std.testing;
// A targets-only ('.') read parses to zero data MIME types and is
// served without a prompt; any data type, even alongside the
// targets listing, still prompts.
try testing.expect(readPromptExempt(0));
try testing.expect(!readPromptExempt(1));
}
test "payload: mime iterator" {
const testing = std.testing;
// base64 of "text/plain text/html\ntext/uri-list"

View File

@@ -844,9 +844,14 @@ pub const Handler = struct {
};
// Per the spec a password without a name is no password. A
// stored grant for it lets the embedder skip its prompt.
// stored grant for it lets the embedder skip its prompt. A
// prompt-exempt request never consults the grants: the
// embedder serves it without a prompt anyway, and consuming a
// one-time paste password on a listing would burn the grant
// before the follow-up data read.
const pw: []const u8 = if (meta.name.len > 0) meta.pw else "";
const granted = self.kitty_clipboard_grants.use(alloc, pw, .read);
const granted = !kitty_clipboard.readPromptExempt(mimes.len) and
self.kitty_clipboard_grants.use(alloc, pw, .read);
var state: KittyClipboardReadState = .{
.handler = self,
@@ -3999,10 +4004,13 @@ test "kitty clipboard read password grants" {
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
// Every read requests a data type ("text/plain"): a request with
// no data types never consults the grants at all.
//
// pw="secret", name="app": the first request isn't granted but the
// reply may ask to remember it.
S.read_result = .{ .success = .{ .remember = true } };
s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0:name=YXBw\x1B\\");
s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0:name=YXBw;dGV4dC9wbGFpbg==\x1B\\");
try testing.expectEqual(@as(usize, 1), S.read_count);
try testing.expectEqualStrings("app", S.readName());
try testing.expect(!S.last_read_granted);
@@ -4010,30 +4018,30 @@ test "kitty clipboard read password grants" {
// The same password is now granted; a different one is not.
S.read_result = .{ .success = .{} };
s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0:name=YXBw\x1B\\");
s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0:name=YXBw;dGV4dC9wbGFpbg==\x1B\\");
try testing.expect(S.last_read_granted);
s.nextSlice("\x1B]5522;type=read:pw=b3RoZXI=:name=YXBw\x1B\\");
s.nextSlice("\x1B]5522;type=read:pw=b3RoZXI=:name=YXBw;dGV4dC9wbGFpbg==\x1B\\");
try testing.expect(!S.last_read_granted);
try testing.expect(S.last_read_can_remember);
// A password without a name doesn't count: it is neither granted
// nor rememberable, even if the reply asks.
S.read_result = .{ .success = .{ .remember = true } };
s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0\x1B\\");
s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0;dGV4dC9wbGFpbg==\x1B\\");
try testing.expectEqualStrings("", S.readName());
try testing.expect(!S.last_read_granted);
try testing.expect(!S.last_read_can_remember);
s.nextSlice("\x1B]5522;type=read:pw=b3RoZXI=\x1B\\");
s.nextSlice("\x1B]5522;type=read:pw=b3RoZXI=;dGV4dC9wbGFpbg==\x1B\\");
try testing.expect(!S.last_read_can_remember);
S.read_result = .{ .success = .{} };
s.nextSlice("\x1B]5522;type=read:pw=b3RoZXI=:name=YXBw\x1B\\");
s.nextSlice("\x1B]5522;type=read:pw=b3RoZXI=:name=YXBw;dGV4dC9wbGFpbg==\x1B\\");
try testing.expect(!S.last_read_granted);
// A grant is advisory: the request is still forwarded and the
// embedder may deny it.
S.responses_len = 0;
S.read_result = .denied;
s.nextSlice("\x1B]5522;type=read:id=d:pw=c2VjcmV0:name=YXBw\x1B\\");
s.nextSlice("\x1B]5522;type=read:id=d:pw=c2VjcmV0:name=YXBw;dGV4dC9wbGFpbg==\x1B\\");
try testing.expect(S.last_read_granted);
try testing.expectEqualStrings(
"\x1B]5522;type=read:status=EPERM:id=d\x1B\\",
@@ -4044,6 +4052,38 @@ test "kitty clipboard read password grants" {
// the leak otherwise).
}
test "kitty clipboard read targets-only never consumes a one-time grant" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = KittyClipboardCapture;
S.reset();
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_read = &S.clipboardRead;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
// A one-time read grant, as minted for a paste event.
try s.handler.kitty_clipboard_grants.grant(testing.allocator, "otp", .read, true);
// A targets-only read (payload ".") is prompt-exempt so it never
// consults, and must not burn, the one-time password.
S.read_result = .{ .success = .{} };
s.nextSlice("\x1B]5522;type=read:pw=b3Rw:name=YXBw;Lg==\x1B\\");
try testing.expectEqual(@as(usize, 1), S.read_count);
try testing.expect(S.last_read_list);
try testing.expectEqual(@as(usize, 0), S.last_read_mimes_len);
try testing.expect(!S.last_read_granted);
// The follow-up data read still consumes the grant, exactly once.
s.nextSlice("\x1B]5522;type=read:pw=b3Rw:name=YXBw;dGV4dC9wbGFpbg==\x1B\\");
try testing.expect(S.last_read_granted);
s.nextSlice("\x1B]5522;type=read:pw=b3Rw:name=YXBw;dGV4dC9wbGFpbg==\x1B\\");
try testing.expect(!S.last_read_granted);
}
test "kitty clipboard write password grants" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);

View File

@@ -1107,7 +1107,7 @@ pub const StreamHandler = struct {
// stored session grant for it lets the surface skip its
// permission prompt.
const pw: []const u8 = if (meta.name.len > 0) meta.pw else "";
const granted = self.kitty_clipboard_grants.use(self.alloc, pw, .read);
const granted = self.kittyClipboardReadGranted(pw, mimes_len);
const req = try alloc.create(apprt.ClipboardRequest.KittyRead);
const mimes = try alloc.alloc([:0]const u8, mimes_len);
@@ -1137,6 +1137,19 @@ pub const StreamHandler = struct {
self.surfaceMessageWriter(.{ .kitty_clipboard_read = req });
}
/// Whether a session grant covers a read request, consuming
/// one-time grants. A prompt-exempt request never consults the
/// grants: consuming a one-time paste password on a listing would
/// burn the grant before the follow-up data read.
fn kittyClipboardReadGranted(
self: *StreamHandler,
pw: []const u8,
mimes_len: usize,
) bool {
if (terminal.kitty.clipboard.readPromptExempt(mimes_len)) return false;
return self.kitty_clipboard_grants.use(self.alloc, pw, .read);
}
/// Begin a Kitty clipboard write transaction (type=write).
fn kittyClipboardWriteBegin(
self: *StreamHandler,
@@ -1826,3 +1839,19 @@ pub const StreamHandler = struct {
self.surfaceMessageWriter(.{ .progress_report = report });
}
};
test "kitty clipboard read: targets-only never consumes a one-time grant" {
const testing = std.testing;
var handler: StreamHandler = undefined;
handler.alloc = testing.allocator;
handler.kitty_clipboard_grants = .{};
defer handler.kitty_clipboard_grants.deinit(testing.allocator);
try handler.kitty_clipboard_grants.grant(testing.allocator, "otp", .read, true);
// A listing request must not burn the one-time paste password...
try testing.expect(!handler.kittyClipboardReadGranted("otp", 0));
// ...so the follow-up data read is still granted, exactly once.
try testing.expect(handler.kittyClipboardReadGranted("otp", 1));
try testing.expect(!handler.kittyClipboardReadGranted("otp", 1));
}