Commit 4d398e8

External.Kai-Fronsdal  ·  2026-03-04 12:17:55 -0500 EST
parent b2833e7
fix(run): always single-quote args to avoid bash history expansion bug

The previous shellQuote preferred double quotes, but \! does not
suppress history expansion inside double quotes in interactive bash.
Switch to always using single quotes (matching Python's shlex.quote),
which are universally safe since nothing is special inside single
quotes except ' itself.

Add unit tests for shellNeedsQuoting and shellQuote.

Fixes #72

Made-with: Cursor
1 files changed,  +147, -86
+147, -86
......@@ -281,25 +281,29 @@ const Daemon = struct {
281281 const cur_cmd = self.command orelse self.task_command;
282282 if (cur_cmd) |args| {
283283 for (args, 0..) |arg, i| {
284- if (i > 0) {
285- if (cmd_len < ipc.MAX_CMD_LEN) {
286- cmd_buf[cmd_len] = ' ';
287- cmd_len += 1;
284+ const quoted = if (shellNeedsQuoting(arg))
285+ shellQuote(self.alloc, arg) catch null
286+ else
287+ null;
288+ defer if (quoted) |q| self.alloc.free(q);
289+ const src = quoted orelse arg;
290+
291+ const need = src.len + @as(usize, if (i > 0) 1 else 0);
292+ if (cmd_len + need > ipc.MAX_CMD_LEN) {
293+ const ellipsis = "...";
294+ if (cmd_len + ellipsis.len <= ipc.MAX_CMD_LEN) {
295+ @memcpy(cmd_buf[cmd_len..][0..ellipsis.len], ellipsis);
296+ cmd_len += ellipsis.len;
288297 }
298+ break;
289299 }
290- if (shellNeedsQuoting(arg)) {
291- const quoted = shellQuote(self.alloc, arg) catch arg;
292- defer if (quoted.ptr != arg.ptr) self.alloc.free(quoted);
293- const remaining = ipc.MAX_CMD_LEN - cmd_len;
294- const copy_len: u16 = @intCast(@min(quoted.len, remaining));
295- @memcpy(cmd_buf[cmd_len..][0..copy_len], quoted[0..copy_len]);
296- cmd_len += copy_len;
297- } else {
298- const remaining = ipc.MAX_CMD_LEN - cmd_len;
299- const copy_len: u16 = @intCast(@min(arg.len, remaining));
300- @memcpy(cmd_buf[cmd_len..][0..copy_len], arg[0..copy_len]);
301- cmd_len += copy_len;
300+
301+ if (i > 0) {
302+ cmd_buf[cmd_len] = ' ';
303+ cmd_len += 1;
302304 }
305+ @memcpy(cmd_buf[cmd_len..][0..src.len], src);
306+ cmd_len += @intCast(src.len);
303307 }
304308 }
305309
......@@ -977,46 +981,15 @@ fn shellNeedsQuoting(arg: []const u8) bool {
977981 }
978982
979983 fn shellQuote(alloc: std.mem.Allocator, arg: []const u8) ![]u8 {
980- // Prefer double quotes when the arg has no double quotes (cleaner output).
981- // Fall back to single quotes with '\'' escaping for embedded single quotes.
982- const has_double_quote = std.mem.indexOfScalar(u8, arg, '"') != null;
983-
984- if (!has_double_quote) {
985- // Double-quote style: escape only $ ` \ ! "
986- var len: usize = 2; // opening and closing "
987- for (arg) |ch| {
988- if (ch == '$' or ch == '`' or ch == '\\' or ch == '!') {
989- len += 2; // backslash + char
990- } else {
991- len += 1;
992- }
993- }
994- const buf = try alloc.alloc(u8, len);
995- var i: usize = 0;
996- buf[i] = '"';
997- i += 1;
998- for (arg) |ch| {
999- if (ch == '$' or ch == '`' or ch == '\\' or ch == '!') {
1000- buf[i] = '\\';
1001- buf[i + 1] = ch;
1002- i += 2;
1003- } else {
1004- buf[i] = ch;
1005- i += 1;
1006- }
1007- }
1008- buf[i] = '"';
1009- return buf;
1010- }
1011-
1012- // Single-quote style: escape embedded single quotes as '\''
984+ // Always use single quotes (like Python's shlex.quote). Inside single
985+ // quotes nothing is special except ' itself, which we handle with the
986+ // '\'' trick (end quote, escaped literal quote, reopen quote).
987+ //
988+ // The previous double-quote strategy broke in interactive bash because
989+ // \! does not suppress history expansion inside double quotes.
1013990 var len: usize = 2;
1014991 for (arg) |ch| {
1015- if (ch == '\'') {
1016- len += 4;
1017- } else {
1018- len += 1;
1019- }
992+ len += if (ch == '\'') 4 else 1;
1020993 }
1021994 const buf = try alloc.alloc(u8, len);
1022995 var i: usize = 0;
......@@ -1024,10 +997,7 @@ fn shellQuote(alloc: std.mem.Allocator, arg: []const u8) ![]u8 {
1024997 i += 1;
1025998 for (arg) |ch| {
1026999 if (ch == '\'') {
1027- buf[i] = '\'';
1028- buf[i + 1] = '\\';
1029- buf[i + 2] = '\'';
1030- buf[i + 3] = '\'';
1000+ @memcpy(buf[i..][0..4], "'\\''");
10311001 i += 4;
10321002 } else {
10331003 buf[i] = ch;
......@@ -1067,42 +1037,25 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
10671037 "echo ZMX_TASK_COMPLETED:$?";
10681038
10691039 if (command_args.len > 0) {
1070- var parts: std.ArrayList([]const u8) = .empty;
1071- defer {
1072- for (parts.items) |part| alloc.free(part);
1073- parts.deinit(alloc);
1074- }
1040+ var cmd_list = std.ArrayList(u8).empty;
1041+ defer cmd_list.deinit(alloc);
10751042
1076- var total_len: usize = 0;
1077- for (command_args) |arg| {
1043+ for (command_args, 0..) |arg, i| {
1044+ if (i > 0) try cmd_list.append(alloc, ' ');
10781045 if (shellNeedsQuoting(arg)) {
10791046 const quoted = try shellQuote(alloc, arg);
1080- try parts.append(alloc, quoted);
1081- total_len += quoted.len + 1;
1047+ defer alloc.free(quoted);
1048+ try cmd_list.appendSlice(alloc, quoted);
10821049 } else {
1083- const duped = try alloc.dupe(u8, arg);
1084- try parts.append(alloc, duped);
1085- total_len += duped.len + 1;
1050+ try cmd_list.appendSlice(alloc, arg);
10861051 }
10871052 }
10881053
1089- total_len += inline_task_marker.len + 1;
1090-
1091- const cmd_buf = try alloc.alloc(u8, total_len);
1092- allocated_cmd = cmd_buf;
1054+ try cmd_list.appendSlice(alloc, inline_task_marker);
1055+ try cmd_list.append(alloc, '\n');
10931056
1094- var offset: usize = 0;
1095- for (parts.items) |part| {
1096- @memcpy(cmd_buf[offset .. offset + part.len], part);
1097- offset += part.len;
1098- cmd_buf[offset] = ' ';
1099- offset += 1;
1100- }
1101-
1102- @memcpy(cmd_buf[offset .. offset + inline_task_marker.len], inline_task_marker);
1103- offset += inline_task_marker.len;
1104- cmd_buf[offset] = '\n';
1105- cmd_to_send = cmd_buf;
1057+ cmd_to_send = try cmd_list.toOwnedSlice(alloc);
1058+ allocated_cmd = @constCast(cmd_to_send.?);
11061059 } else {
11071060 const stdin_fd = posix.STDIN_FILENO;
11081061 if (!std.posix.isatty(stdin_fd)) {
......@@ -1913,6 +1866,114 @@ fn serializeTerminal(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal, forma
19131866 };
19141867 }
19151868
1869+test "shellNeedsQuoting" {
1870+ try std.testing.expect(shellNeedsQuoting(""));
1871+ try std.testing.expect(shellNeedsQuoting("hello world"));
1872+ try std.testing.expect(shellNeedsQuoting("hello!"));
1873+ try std.testing.expect(shellNeedsQuoting("$PATH"));
1874+ try std.testing.expect(shellNeedsQuoting("it's"));
1875+ try std.testing.expect(shellNeedsQuoting("a|b"));
1876+ try std.testing.expect(shellNeedsQuoting("a;b"));
1877+ try std.testing.expect(!shellNeedsQuoting("hello"));
1878+ try std.testing.expect(!shellNeedsQuoting("bash"));
1879+ try std.testing.expect(!shellNeedsQuoting("-c"));
1880+ try std.testing.expect(!shellNeedsQuoting("/usr/bin/env"));
1881+}
1882+
1883+test "shellQuote" {
1884+ const alloc = std.testing.allocator;
1885+
1886+ const empty = try shellQuote(alloc, "");
1887+ defer alloc.free(empty);
1888+ try std.testing.expectEqualStrings("''", empty);
1889+
1890+ const space = try shellQuote(alloc, "hello world");
1891+ defer alloc.free(space);
1892+ try std.testing.expectEqualStrings("'hello world'", space);
1893+
1894+ const bang = try shellQuote(alloc, "hello!");
1895+ defer alloc.free(bang);
1896+ try std.testing.expectEqualStrings("'hello!'", bang);
1897+
1898+ const dollar = try shellQuote(alloc, "$PATH");
1899+ defer alloc.free(dollar);
1900+ try std.testing.expectEqualStrings("'$PATH'", dollar);
1901+
1902+ const sq = try shellQuote(alloc, "it's");
1903+ defer alloc.free(sq);
1904+ try std.testing.expectEqualStrings("'it'\\''s'", sq);
1905+
1906+ const dq = try shellQuote(alloc, "say \"hi\"");
1907+ defer alloc.free(dq);
1908+ try std.testing.expectEqualStrings("'say \"hi\"'", dq);
1909+
1910+ const both = try shellQuote(alloc, "it's \"cool\"");
1911+ defer alloc.free(both);
1912+ try std.testing.expectEqualStrings("'it'\\''s \"cool\"'", both);
1913+
1914+ // just a single quote
1915+ const lone_sq = try shellQuote(alloc, "'");
1916+ defer alloc.free(lone_sq);
1917+ try std.testing.expectEqualStrings("''\\'''", lone_sq);
1918+
1919+ // multiple consecutive single quotes
1920+ const triple_sq = try shellQuote(alloc, "'''");
1921+ defer alloc.free(triple_sq);
1922+ try std.testing.expectEqualStrings("''\\'''\\'''\\'''", triple_sq);
1923+
1924+ // backtick command substitution
1925+ const backtick = try shellQuote(alloc, "`whoami`");
1926+ defer alloc.free(backtick);
1927+ try std.testing.expectEqualStrings("'`whoami`'", backtick);
1928+
1929+ // dollar command substitution
1930+ const dollar_cmd = try shellQuote(alloc, "$(whoami)");
1931+ defer alloc.free(dollar_cmd);
1932+ try std.testing.expectEqualStrings("'$(whoami)'", dollar_cmd);
1933+
1934+ // glob
1935+ const glob = try shellQuote(alloc, "*.txt");
1936+ defer alloc.free(glob);
1937+ try std.testing.expectEqualStrings("'*.txt'", glob);
1938+
1939+ // tilde
1940+ const tilde = try shellQuote(alloc, "~/file");
1941+ defer alloc.free(tilde);
1942+ try std.testing.expectEqualStrings("'~/file'", tilde);
1943+
1944+ // trailing backslash
1945+ const trailing_bs = try shellQuote(alloc, "path\\");
1946+ defer alloc.free(trailing_bs);
1947+ try std.testing.expectEqualStrings("'path\\'", trailing_bs);
1948+
1949+ // semicolon (command injection)
1950+ const semi = try shellQuote(alloc, "; rm -rf /");
1951+ defer alloc.free(semi);
1952+ try std.testing.expectEqualStrings("'; rm -rf /'", semi);
1953+
1954+ // embedded newline
1955+ const newline = try shellQuote(alloc, "line1\nline2");
1956+ defer alloc.free(newline);
1957+ try std.testing.expectEqualStrings("'line1\nline2'", newline);
1958+
1959+ // parentheses (subshell)
1960+ const parens = try shellQuote(alloc, "(echo hi)");
1961+ defer alloc.free(parens);
1962+ try std.testing.expectEqualStrings("'(echo hi)'", parens);
1963+
1964+ // heredoc marker
1965+ const heredoc = try shellQuote(alloc, "<<EOF");
1966+ defer alloc.free(heredoc);
1967+ try std.testing.expectEqualStrings("'<<EOF'", heredoc);
1968+
1969+ // no quoting needed -- plain word should still be quoted
1970+ // (shellQuote is only called when shellNeedsQuoting returns true,
1971+ // but verify it produces valid output anyway)
1972+ const plain = try shellQuote(alloc, "hello");
1973+ defer alloc.free(plain);
1974+ try std.testing.expectEqualStrings("'hello'", plain);
1975+}
1976+
19161977 test "isKittyCtrlBackslash" {
19171978 try std.testing.expect(isKittyCtrlBackslash("\x1b[92;5u"));
19181979 try std.testing.expect(isKittyCtrlBackslash("\x1b[92;5:1u"));