clay-server 2.40.0-beta.3 → 2.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/claude-hook-installer.js +24 -76
- package/lib/cli-sessions.js +80 -45
- package/lib/project-connection.js +9 -1
- package/lib/project-sessions.js +132 -55
- package/lib/project.js +1 -0
- package/lib/public/css/filebrowser.css +2 -2
- package/lib/public/css/input.css +12 -0
- package/lib/public/css/menus.css +23 -0
- package/lib/public/css/sidebar.css +0 -85
- package/lib/public/css/tui-attention.css +41 -0
- package/lib/public/index.html +2 -1
- package/lib/public/modules/app-messages.js +4 -1
- package/lib/public/modules/input.js +43 -5
- package/lib/public/modules/session-tui-view.js +224 -10
- package/lib/public/modules/sidebar-mobile.js +2 -1
- package/lib/public/modules/sidebar-sessions.js +11 -83
- package/lib/public/modules/terminal-toolbar.js +129 -0
- package/lib/public/modules/terminal.js +15 -99
- package/lib/safe-bash-commands.js +171 -0
- package/lib/sdk-bridge.js +6 -51
- package/lib/sessions.js +1 -1
- package/lib/terminal-manager.js +44 -0
- package/lib/ws-schema.js +2 -0
- package/package.json +1 -1
|
@@ -292,96 +292,24 @@ function appendSessionCloseButton(el, session) {
|
|
|
292
292
|
el.appendChild(closeBtn);
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
-
// Dropdown anchored to the Claude split-button chevron. Renders two
|
|
296
|
-
// explicit choices (TUI / GUI) and dismisses on click-outside, Escape,
|
|
297
|
-
// or window blur. Kept local to this module since it's the only caller.
|
|
298
|
-
var _claudeModeMenu = null;
|
|
299
|
-
function closeClaudeModeMenu() {
|
|
300
|
-
if (_claudeModeMenu && _claudeModeMenu.parentNode) {
|
|
301
|
-
_claudeModeMenu.parentNode.removeChild(_claudeModeMenu);
|
|
302
|
-
}
|
|
303
|
-
_claudeModeMenu = null;
|
|
304
|
-
}
|
|
305
|
-
function openClaudeModeMenu(x, y) {
|
|
306
|
-
closeClaudeModeMenu();
|
|
307
|
-
var menu = document.createElement("div");
|
|
308
|
-
menu.className = "claude-mode-menu";
|
|
309
|
-
// CLI sessions (created by the `claude` CLI outside Clay) are now
|
|
310
|
-
// auto-adopted into the regular session list on server start, so no
|
|
311
|
-
// separate "Import CLI" entry is needed. Users see all sessions as one
|
|
312
|
-
// list and click renders per their claudeOpenMode pref.
|
|
313
|
-
var items = [
|
|
314
|
-
{ label: "Start as TUI", hint: "Real claude terminal", action: "new", mode: "tui" },
|
|
315
|
-
{ label: "Start as GUI", hint: "Clay chat UI", action: "new", mode: "gui" },
|
|
316
|
-
];
|
|
317
|
-
for (var i = 0; i < items.length; i++) {
|
|
318
|
-
(function (it) {
|
|
319
|
-
var b = document.createElement("button");
|
|
320
|
-
b.type = "button";
|
|
321
|
-
b.className = "claude-mode-menu-item";
|
|
322
|
-
b.innerHTML = '<span class="claude-mode-menu-label">' + it.label + '</span>' +
|
|
323
|
-
'<span class="claude-mode-menu-hint">' + it.hint + '</span>';
|
|
324
|
-
b.addEventListener("click", function (e) {
|
|
325
|
-
e.stopPropagation();
|
|
326
|
-
closeClaudeModeMenu();
|
|
327
|
-
if (getWs() && store.get('connected')) {
|
|
328
|
-
getWs().send(JSON.stringify({ type: "new_session", vendor: "claude", mode: it.mode }));
|
|
329
|
-
}
|
|
330
|
-
});
|
|
331
|
-
menu.appendChild(b);
|
|
332
|
-
})(items[i]);
|
|
333
|
-
}
|
|
334
|
-
document.body.appendChild(menu);
|
|
335
|
-
// Clamp to viewport.
|
|
336
|
-
var rect = menu.getBoundingClientRect();
|
|
337
|
-
var px = x, py = y;
|
|
338
|
-
if (px + rect.width > window.innerWidth - 4) px = window.innerWidth - rect.width - 4;
|
|
339
|
-
if (py + rect.height > window.innerHeight - 4) py = window.innerHeight - rect.height - 4;
|
|
340
|
-
menu.style.left = px + "px";
|
|
341
|
-
menu.style.top = py + "px";
|
|
342
|
-
_claudeModeMenu = menu;
|
|
343
|
-
}
|
|
344
|
-
document.addEventListener("click", function () { closeClaudeModeMenu(); });
|
|
345
|
-
document.addEventListener("keydown", function (e) { if (e.key === "Escape") closeClaudeModeMenu(); });
|
|
346
|
-
window.addEventListener("blur", function () { closeClaudeModeMenu(); });
|
|
347
|
-
window.addEventListener("resize", function () { closeClaudeModeMenu(); });
|
|
348
|
-
|
|
349
295
|
function renderSessionTopActions() {
|
|
350
296
|
var wrap = document.createElement("div");
|
|
351
297
|
wrap.className = "session-top-actions";
|
|
352
298
|
|
|
353
|
-
// Claude:
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
claudeMain.type = "button";
|
|
363
|
-
claudeMain.title = "New Claude session (TUI)";
|
|
364
|
-
claudeMain.innerHTML = '<img src="/claude-code-avatar.png" class="session-top-action-icon" alt=""><span>Claude</span>';
|
|
365
|
-
claudeMain.addEventListener("click", function () {
|
|
299
|
+
// Claude: single button. TUI vs GUI is the user's claudeOpenMode pref
|
|
300
|
+
// (set in user settings), so we send no explicit mode and let the server
|
|
301
|
+
// apply it - no per-click chevron/menu needed.
|
|
302
|
+
var claudeBtn = document.createElement("button");
|
|
303
|
+
claudeBtn.className = "session-top-action";
|
|
304
|
+
claudeBtn.type = "button";
|
|
305
|
+
claudeBtn.title = "New Claude session";
|
|
306
|
+
claudeBtn.innerHTML = '<img src="/claude-code-avatar.png" class="session-top-action-icon" alt=""><span>Claude</span>';
|
|
307
|
+
claudeBtn.addEventListener("click", function () {
|
|
366
308
|
if (getWs() && store.get('connected')) {
|
|
367
|
-
getWs().send(JSON.stringify({ type: "new_session", vendor: "claude"
|
|
309
|
+
getWs().send(JSON.stringify({ type: "new_session", vendor: "claude" }));
|
|
368
310
|
}
|
|
369
311
|
});
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
var claudeChevron = document.createElement("button");
|
|
373
|
-
claudeChevron.className = "session-top-action-chevron";
|
|
374
|
-
claudeChevron.type = "button";
|
|
375
|
-
claudeChevron.title = "Choose session mode";
|
|
376
|
-
claudeChevron.innerHTML = iconHtml("chevron-down");
|
|
377
|
-
claudeChevron.addEventListener("click", function (e) {
|
|
378
|
-
e.stopPropagation();
|
|
379
|
-
var rect = claudeWrap.getBoundingClientRect();
|
|
380
|
-
openClaudeModeMenu(rect.left, rect.bottom + 4);
|
|
381
|
-
});
|
|
382
|
-
claudeWrap.appendChild(claudeChevron);
|
|
383
|
-
|
|
384
|
-
wrap.appendChild(claudeWrap);
|
|
312
|
+
wrap.appendChild(claudeBtn);
|
|
385
313
|
|
|
386
314
|
// Codex: always GUI (no TUI adapter for Codex).
|
|
387
315
|
var codexBtn = document.createElement("button");
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// terminal-toolbar.js
|
|
2
|
+
//
|
|
3
|
+
// Reusable mobile control-key bar (Tab / Ctrl / Esc / arrows / Alt / pipe /
|
|
4
|
+
// slash / tilde) for any xterm-backed terminal. Soft keyboards lack these
|
|
5
|
+
// keys, so both the bottom-panel shell (terminal.js) and the embedded TUI
|
|
6
|
+
// session view (session-tui-view.js) mount this bar on touch devices.
|
|
7
|
+
//
|
|
8
|
+
// The caller owns the toolbar element and how bytes reach its terminal; this
|
|
9
|
+
// module owns the key sequences, the sticky Ctrl/Alt modifiers, and applying
|
|
10
|
+
// Ctrl to a soft-keyboard letter via xterm's custom key handler.
|
|
11
|
+
|
|
12
|
+
export var TERMINAL_TOOLBAR_HTML =
|
|
13
|
+
'<button class="term-key" data-key="tab">Tab</button>' +
|
|
14
|
+
'<button class="term-key term-key-toggle" data-key="ctrl">Ctrl</button>' +
|
|
15
|
+
'<button class="term-key" data-key="esc">Esc</button>' +
|
|
16
|
+
'<span class="term-key-spacer"></span>' +
|
|
17
|
+
'<button class="term-key term-key-arrow" data-key="up">▲</button>' +
|
|
18
|
+
'<button class="term-key term-key-arrow" data-key="down">▼</button>' +
|
|
19
|
+
'<button class="term-key term-key-arrow" data-key="left">◀</button>' +
|
|
20
|
+
'<button class="term-key term-key-arrow" data-key="right">▶</button>' +
|
|
21
|
+
'<span class="term-key-spacer"></span>' +
|
|
22
|
+
'<button class="term-key term-key-toggle" data-key="alt">Alt</button>' +
|
|
23
|
+
'<button class="term-key" data-key="pipe">|</button>' +
|
|
24
|
+
'<button class="term-key" data-key="slash">/</button>' +
|
|
25
|
+
'<button class="term-key" data-key="tilde">~</button>';
|
|
26
|
+
|
|
27
|
+
var KEY_MAP = {
|
|
28
|
+
tab: "\t",
|
|
29
|
+
esc: "\x1b",
|
|
30
|
+
up: "\x1b[A",
|
|
31
|
+
down: "\x1b[B",
|
|
32
|
+
right: "\x1b[C",
|
|
33
|
+
left: "\x1b[D",
|
|
34
|
+
pipe: "|",
|
|
35
|
+
slash: "/",
|
|
36
|
+
tilde: "~",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Wire a toolbar element to a terminal.
|
|
40
|
+
// opts.toolbar : the container element holding the .term-key buttons.
|
|
41
|
+
// opts.send : function(data) that writes the bytes to the live PTY.
|
|
42
|
+
// Returns { bindXterm(xterm), reset() }:
|
|
43
|
+
// bindXterm - (re)attach the Ctrl-letter handler to the active xterm; call
|
|
44
|
+
// whenever the active terminal changes.
|
|
45
|
+
// reset - clear sticky Ctrl/Alt state (call when hiding the bar).
|
|
46
|
+
export function createKeyToolbar(opts) {
|
|
47
|
+
var toolbar = opts && opts.toolbar;
|
|
48
|
+
var send = opts && opts.send;
|
|
49
|
+
if (!toolbar || typeof send !== "function") return { bindXterm: function () {}, reset: function () {} };
|
|
50
|
+
|
|
51
|
+
var ctrlActive = false;
|
|
52
|
+
var altActive = false;
|
|
53
|
+
|
|
54
|
+
function clearModifier(key) {
|
|
55
|
+
var btn = toolbar.querySelector("[data-key='" + key + "']");
|
|
56
|
+
if (btn) btn.classList.remove("active");
|
|
57
|
+
}
|
|
58
|
+
function reset() {
|
|
59
|
+
ctrlActive = false;
|
|
60
|
+
altActive = false;
|
|
61
|
+
clearModifier("ctrl");
|
|
62
|
+
clearModifier("alt");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Bind the click handler once.
|
|
66
|
+
if (!toolbar._keyToolbarBound) {
|
|
67
|
+
toolbar._keyToolbarBound = true;
|
|
68
|
+
// Keep focus on the terminal so the soft keyboard doesn't dismiss.
|
|
69
|
+
toolbar.addEventListener("mousedown", function (e) { e.preventDefault(); });
|
|
70
|
+
toolbar.addEventListener("click", function (e) {
|
|
71
|
+
var btn = e.target.closest(".term-key");
|
|
72
|
+
if (!btn) return;
|
|
73
|
+
var key = btn.dataset.key;
|
|
74
|
+
if (!key) return;
|
|
75
|
+
|
|
76
|
+
if (key === "ctrl") {
|
|
77
|
+
ctrlActive = !ctrlActive;
|
|
78
|
+
btn.classList.toggle("active", ctrlActive);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (key === "alt") {
|
|
82
|
+
altActive = !altActive;
|
|
83
|
+
btn.classList.toggle("active", altActive);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
var seq = KEY_MAP[key];
|
|
88
|
+
if (!seq) return;
|
|
89
|
+
if (altActive) {
|
|
90
|
+
seq = "\x1b" + seq;
|
|
91
|
+
altActive = false;
|
|
92
|
+
clearModifier("alt");
|
|
93
|
+
}
|
|
94
|
+
send(seq);
|
|
95
|
+
if (ctrlActive) {
|
|
96
|
+
ctrlActive = false;
|
|
97
|
+
clearModifier("ctrl");
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
bindXterm: function (xterm) {
|
|
104
|
+
if (!xterm || typeof xterm.attachCustomKeyEventHandler !== "function") return;
|
|
105
|
+
xterm.attachCustomKeyEventHandler(function (ev) {
|
|
106
|
+
if (ctrlActive && ev.type === "keydown" && ev.key && ev.key.length === 1) {
|
|
107
|
+
var charCode = ev.key.toUpperCase().charCodeAt(0);
|
|
108
|
+
if (charCode >= 65 && charCode <= 90) {
|
|
109
|
+
send(String.fromCharCode(charCode - 64));
|
|
110
|
+
ctrlActive = false;
|
|
111
|
+
clearModifier("ctrl");
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return true;
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
// If Ctrl is armed, disarm it and return true. Lets a separate input
|
|
119
|
+
// surface (e.g. the mobile TUI entry bar, where soft-keyboard letters
|
|
120
|
+
// don't reach xterm) apply the Ctrl modifier to the next typed letter.
|
|
121
|
+
takeCtrl: function () {
|
|
122
|
+
if (!ctrlActive) return false;
|
|
123
|
+
ctrlActive = false;
|
|
124
|
+
clearModifier("ctrl");
|
|
125
|
+
return true;
|
|
126
|
+
},
|
|
127
|
+
reset: reset,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -4,13 +4,13 @@ import { closeFileViewer } from './filebrowser.js';
|
|
|
4
4
|
import { copyToClipboard } from './utils.js';
|
|
5
5
|
import { getTerminalTheme } from './theme.js';
|
|
6
6
|
import { getTerminalFontFamily, getTerminalFontSize, onTerminalFontChange } from './terminal-prefs.js';
|
|
7
|
+
import { createKeyToolbar } from './terminal-toolbar.js';
|
|
7
8
|
|
|
8
9
|
var ctx;
|
|
9
10
|
var tabs = new Map(); // termId -> { id, title, exited, xterm, fitAddon, bodyEl }
|
|
10
11
|
var activeTabId = null;
|
|
11
12
|
var isOpen = false;
|
|
12
|
-
var
|
|
13
|
-
var altActive = false;
|
|
13
|
+
var keyToolbar = null;
|
|
14
14
|
var isTouchDevice = "ontouchstart" in window;
|
|
15
15
|
var viewportHandler = null;
|
|
16
16
|
var resizeObserver = null;
|
|
@@ -30,7 +30,6 @@ function disposeTab(tab) {
|
|
|
30
30
|
tab.bodyEl = null;
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
|
-
var toolbarBound = false;
|
|
34
33
|
var termCtxMenu = null;
|
|
35
34
|
|
|
36
35
|
// --- Multi-line link provider ---
|
|
@@ -242,15 +241,8 @@ export function closeTerminal() {
|
|
|
242
241
|
|
|
243
242
|
// Hide toolbar
|
|
244
243
|
var toolbar = document.getElementById("terminal-toolbar");
|
|
245
|
-
if (toolbar)
|
|
246
|
-
|
|
247
|
-
var ctrlBtn = toolbar.querySelector("[data-key='ctrl']");
|
|
248
|
-
if (ctrlBtn) ctrlBtn.classList.remove("active");
|
|
249
|
-
var altBtn = toolbar.querySelector("[data-key='alt']");
|
|
250
|
-
if (altBtn) altBtn.classList.remove("active");
|
|
251
|
-
}
|
|
252
|
-
ctrlActive = false;
|
|
253
|
-
altActive = false;
|
|
244
|
+
if (toolbar) toolbar.classList.add("hidden");
|
|
245
|
+
if (keyToolbar) keyToolbar.reset();
|
|
254
246
|
|
|
255
247
|
// Mobile: restore tab bar
|
|
256
248
|
var mTabBar = document.getElementById("mobile-tab-bar");
|
|
@@ -327,7 +319,17 @@ function activateTab(termId) {
|
|
|
327
319
|
var toolbar = document.getElementById("terminal-toolbar");
|
|
328
320
|
if (toolbar && isTouchDevice) {
|
|
329
321
|
toolbar.classList.remove("hidden");
|
|
330
|
-
|
|
322
|
+
if (!keyToolbar) {
|
|
323
|
+
keyToolbar = createKeyToolbar({
|
|
324
|
+
toolbar: toolbar,
|
|
325
|
+
send: function (data) {
|
|
326
|
+
if (activeTabId && ctx.ws && ctx.connected) {
|
|
327
|
+
ctx.ws.send(JSON.stringify({ type: "term_input", id: activeTabId, data: data }));
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
if (tab.xterm) keyToolbar.bindXterm(tab.xterm);
|
|
331
333
|
}
|
|
332
334
|
|
|
333
335
|
// Mobile viewport handling
|
|
@@ -962,89 +964,3 @@ function showTermCtxMenu(e, tab) {
|
|
|
962
964
|
document.addEventListener("click", closeTermCtxMenu, { once: true });
|
|
963
965
|
}, 0);
|
|
964
966
|
}
|
|
965
|
-
|
|
966
|
-
// --- Mobile toolbar ---
|
|
967
|
-
var KEY_MAP = {
|
|
968
|
-
tab: "\t",
|
|
969
|
-
esc: "\x1b",
|
|
970
|
-
up: "\x1b[A",
|
|
971
|
-
down: "\x1b[B",
|
|
972
|
-
right: "\x1b[C",
|
|
973
|
-
left: "\x1b[D",
|
|
974
|
-
pipe: "|",
|
|
975
|
-
slash: "/",
|
|
976
|
-
tilde: "~",
|
|
977
|
-
};
|
|
978
|
-
|
|
979
|
-
function initToolbar(toolbar) {
|
|
980
|
-
if (!toolbarBound) {
|
|
981
|
-
toolbarBound = true;
|
|
982
|
-
|
|
983
|
-
toolbar.addEventListener("mousedown", function (e) { e.preventDefault(); });
|
|
984
|
-
|
|
985
|
-
toolbar.addEventListener("click", function (e) {
|
|
986
|
-
var btn = e.target.closest(".term-key");
|
|
987
|
-
if (!btn) return;
|
|
988
|
-
|
|
989
|
-
var tab = activeTabId ? tabs.get(activeTabId) : null;
|
|
990
|
-
if (!tab || !tab.xterm) return;
|
|
991
|
-
|
|
992
|
-
var key = btn.dataset.key;
|
|
993
|
-
if (!key) return;
|
|
994
|
-
|
|
995
|
-
if (key === "ctrl") {
|
|
996
|
-
ctrlActive = !ctrlActive;
|
|
997
|
-
btn.classList.toggle("active", ctrlActive);
|
|
998
|
-
return;
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
if (key === "alt") {
|
|
1002
|
-
altActive = !altActive;
|
|
1003
|
-
btn.classList.toggle("active", altActive);
|
|
1004
|
-
return;
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
var seq = KEY_MAP[key];
|
|
1008
|
-
if (!seq) return;
|
|
1009
|
-
|
|
1010
|
-
// Alt prefix: send ESC before the character
|
|
1011
|
-
if (altActive) {
|
|
1012
|
-
seq = "\x1b" + seq;
|
|
1013
|
-
altActive = false;
|
|
1014
|
-
var altBtn = toolbar.querySelector("[data-key='alt']");
|
|
1015
|
-
if (altBtn) altBtn.classList.remove("active");
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
if (ctx.ws && ctx.connected) {
|
|
1019
|
-
ctx.ws.send(JSON.stringify({ type: "term_input", id: activeTabId, data: seq }));
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
if (ctrlActive) {
|
|
1023
|
-
ctrlActive = false;
|
|
1024
|
-
var ctrlBtn = toolbar.querySelector("[data-key='ctrl']");
|
|
1025
|
-
if (ctrlBtn) ctrlBtn.classList.remove("active");
|
|
1026
|
-
}
|
|
1027
|
-
});
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
// Attach Ctrl handler to active terminal
|
|
1031
|
-
var tab = activeTabId ? tabs.get(activeTabId) : null;
|
|
1032
|
-
if (tab && tab.xterm) {
|
|
1033
|
-
tab.xterm.attachCustomKeyEventHandler(function (ev) {
|
|
1034
|
-
if (ctrlActive && ev.type === "keydown" && ev.key.length === 1) {
|
|
1035
|
-
var charCode = ev.key.toUpperCase().charCodeAt(0);
|
|
1036
|
-
if (charCode >= 65 && charCode <= 90) {
|
|
1037
|
-
var ctrlChar = String.fromCharCode(charCode - 64);
|
|
1038
|
-
if (ctx.ws && ctx.connected) {
|
|
1039
|
-
ctx.ws.send(JSON.stringify({ type: "term_input", id: activeTabId, data: ctrlChar }));
|
|
1040
|
-
}
|
|
1041
|
-
ctrlActive = false;
|
|
1042
|
-
var ctrlBtn = document.querySelector("#terminal-toolbar [data-key='ctrl']");
|
|
1043
|
-
if (ctrlBtn) ctrlBtn.classList.remove("active");
|
|
1044
|
-
return false;
|
|
1045
|
-
}
|
|
1046
|
-
}
|
|
1047
|
-
return true;
|
|
1048
|
-
});
|
|
1049
|
-
}
|
|
1050
|
-
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// safe-bash-commands.js
|
|
2
|
+
//
|
|
3
|
+
// Single source of truth for the Bash commands Clay auto-approves without
|
|
4
|
+
// prompting. BOTH auto-approval paths derive from here so they can never
|
|
5
|
+
// drift apart again:
|
|
6
|
+
// - sdk-bridge.js `checkToolWhitelist` -> SDK / GUI sessions (uses
|
|
7
|
+
// isSafeBashSegment on each operator-split segment).
|
|
8
|
+
// - claude-hook-installer.js `CLAY_MANAGED_ALLOW` -> TUI sessions, via the
|
|
9
|
+
// `Bash(...)` patterns buildClayBashAllowPatterns() emits into
|
|
10
|
+
// ~/.claude/settings.json `permissions.allow`.
|
|
11
|
+
//
|
|
12
|
+
// Policy:
|
|
13
|
+
// - STANDALONE: read-only / inspection / navigation / text commands that
|
|
14
|
+
// stay safe regardless of arguments. Auto-approved as a whole word.
|
|
15
|
+
// - SUBCOMMAND: binaries that are NOT blanket-safe (git, npm, ...) but
|
|
16
|
+
// whose listed read-only subcommands are. Write subcommands (git push,
|
|
17
|
+
// npm install, ...) are intentionally absent so they still prompt.
|
|
18
|
+
// - EXACT: full command strings safe verbatim (version probes). Language
|
|
19
|
+
// runtimes (node, python, ruby, go, ...) live here as `--version` only:
|
|
20
|
+
// `node script.js` executes arbitrary code, so it is NOT auto-approved.
|
|
21
|
+
//
|
|
22
|
+
// Safety note: a few STANDALONE entries (env, command, xargs, time, tee,
|
|
23
|
+
// find, sed, awk) can execute or write given hostile arguments
|
|
24
|
+
// (`env rm -rf /`, `find . -exec ...`, `tee /etc/x`). They are kept to match
|
|
25
|
+
// Clay's long-standing read-only-convenience posture; this is the one place
|
|
26
|
+
// to tighten if that tradeoff ever changes.
|
|
27
|
+
|
|
28
|
+
var STANDALONE = [
|
|
29
|
+
// Navigation
|
|
30
|
+
"cd", "pushd", "popd",
|
|
31
|
+
// File / dir inspection
|
|
32
|
+
"ls", "cat", "head", "tail", "wc", "file", "stat", "find", "tree", "du", "df",
|
|
33
|
+
"readlink", "realpath", "basename", "dirname",
|
|
34
|
+
// Search
|
|
35
|
+
"grep", "rg", "ag", "ack", "fgrep", "egrep",
|
|
36
|
+
// Lookup
|
|
37
|
+
"which", "type", "whereis", "command", "hash",
|
|
38
|
+
// Environment / system info
|
|
39
|
+
"echo", "printf", "env", "printenv", "pwd", "whoami", "id", "groups",
|
|
40
|
+
"date", "uname", "hostname", "uptime", "arch", "nproc", "free",
|
|
41
|
+
"lsb_release", "sw_vers", "locale", "timedatectl",
|
|
42
|
+
// Text processing (stdin / stdout)
|
|
43
|
+
"jq", "yq", "sort", "uniq", "cut", "tr", "awk", "sed", "paste", "column",
|
|
44
|
+
"fold", "rev", "tac", "nl", "expand", "unexpand", "fmt", "pr", "csplit",
|
|
45
|
+
"comm", "join",
|
|
46
|
+
// Comparison / hashing
|
|
47
|
+
"diff", "cmp", "md5sum", "sha256sum", "sha1sum", "shasum", "cksum", "sum",
|
|
48
|
+
"b2sum", "base64", "xxd", "od", "hexdump",
|
|
49
|
+
// Misc read-only
|
|
50
|
+
"test", "true", "false", "seq", "yes", "sleep", "tee", "xargs", "time",
|
|
51
|
+
"man", "help", "info", "apropos", "cal", "bc", "expr", "factor",
|
|
52
|
+
// ACL / attribute inspection
|
|
53
|
+
"getfacl", "getfattr", "namei",
|
|
54
|
+
// Process / network introspection
|
|
55
|
+
"lsof", "ps", "top", "htop", "pgrep", "netstat", "ss", "ifconfig", "ip",
|
|
56
|
+
"dig", "nslookup", "host", "ping", "traceroute", "curl", "wget", "http",
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
// binary -> read-only subcommands. Each generates `Bash(bin sub)` and
|
|
60
|
+
// `Bash(bin sub *)`. Multi-word keys (e.g. "config --get") are supported.
|
|
61
|
+
var SUBCOMMAND = {
|
|
62
|
+
git: [
|
|
63
|
+
"status", "log", "diff", "show", "branch", "tag", "remote",
|
|
64
|
+
"rev-parse", "ls-files", "blame", "describe", "config --get",
|
|
65
|
+
],
|
|
66
|
+
npm: ["list", "ls", "view", "outdated", "config get"],
|
|
67
|
+
yarn: ["list"],
|
|
68
|
+
pnpm: ["list"],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Full command strings safe verbatim. `*`-suffixed args are allowed after
|
|
72
|
+
// them (e.g. `node --version` plus any trailing flags) but the prefix must
|
|
73
|
+
// match exactly so `node server.js` does not slip through.
|
|
74
|
+
var EXACT = [
|
|
75
|
+
"node --version", "npm --version", "python --version",
|
|
76
|
+
"python3 --version", "go version", "ruby --version",
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
var STANDALONE_SET = {};
|
|
80
|
+
for (var i = 0; i < STANDALONE.length; i++) STANDALONE_SET[STANDALONE[i]] = true;
|
|
81
|
+
|
|
82
|
+
// Strip leading env assignments (FOO=bar cmd) and an optional sudo prefix so
|
|
83
|
+
// the real command word is what we test. Returns the cleaned segment.
|
|
84
|
+
function stripLeadingNoise(seg) {
|
|
85
|
+
var out = seg.replace(/^(?:\w+=\S*\s+)*/, "");
|
|
86
|
+
if (/^sudo(?:\s|$)/.test(out)) {
|
|
87
|
+
out = out.replace(/^sudo\s+(?:-\S+\s+)*/, "");
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// True if a single shell segment (already split on &&, ||, ;, |) is safe to
|
|
93
|
+
// auto-approve. Empty segments (trailing operators) are treated as safe.
|
|
94
|
+
function isSafeBashSegment(seg) {
|
|
95
|
+
seg = (seg || "").trim();
|
|
96
|
+
if (!seg) return true;
|
|
97
|
+
var cleaned = stripLeadingNoise(seg);
|
|
98
|
+
var firstWord = cleaned.split(/\s+/)[0];
|
|
99
|
+
if (!firstWord) return false;
|
|
100
|
+
if (STANDALONE_SET[firstWord]) return true;
|
|
101
|
+
if (SUBCOMMAND[firstWord]) {
|
|
102
|
+
var subs = SUBCOMMAND[firstWord];
|
|
103
|
+
for (var i = 0; i < subs.length; i++) {
|
|
104
|
+
var prefix = firstWord + " " + subs[i];
|
|
105
|
+
if (cleaned === prefix || cleaned.indexOf(prefix + " ") === 0) return true;
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
for (var j = 0; j < EXACT.length; j++) {
|
|
110
|
+
if (cleaned === EXACT[j] || cleaned.indexOf(EXACT[j] + " ") === 0) return true;
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Emit the `Bash(...)` permission patterns for ~/.claude/settings.json. Each
|
|
116
|
+
// standalone command gets a bare form (zero-arg) and a space-wildcard form
|
|
117
|
+
// (claude 2.x prefix matching); subcommands and exact commands likewise.
|
|
118
|
+
function buildClayBashAllowPatterns() {
|
|
119
|
+
var patterns = [];
|
|
120
|
+
for (var i = 0; i < STANDALONE.length; i++) {
|
|
121
|
+
patterns.push("Bash(" + STANDALONE[i] + ")");
|
|
122
|
+
patterns.push("Bash(" + STANDALONE[i] + " *)");
|
|
123
|
+
}
|
|
124
|
+
var bins = Object.keys(SUBCOMMAND);
|
|
125
|
+
for (var b = 0; b < bins.length; b++) {
|
|
126
|
+
var subs = SUBCOMMAND[bins[b]];
|
|
127
|
+
for (var s = 0; s < subs.length; s++) {
|
|
128
|
+
var base = bins[b] + " " + subs[s];
|
|
129
|
+
patterns.push("Bash(" + base + ")");
|
|
130
|
+
patterns.push("Bash(" + base + " *)");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
for (var e = 0; e < EXACT.length; e++) {
|
|
134
|
+
patterns.push("Bash(" + EXACT[e] + ")");
|
|
135
|
+
patterns.push("Bash(" + EXACT[e] + " *)");
|
|
136
|
+
}
|
|
137
|
+
return patterns;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
var GENERATED_SET = {};
|
|
141
|
+
(function () {
|
|
142
|
+
var p = buildClayBashAllowPatterns();
|
|
143
|
+
for (var i = 0; i < p.length; i++) GENERATED_SET[p[i]] = true;
|
|
144
|
+
})();
|
|
145
|
+
|
|
146
|
+
// Recognize a `permissions.allow` entry that Clay owns, so the installer can
|
|
147
|
+
// strip stale variants on upgrade (including shapes a previous version wrote)
|
|
148
|
+
// WITHOUT clobbering user-authored patterns. Rules:
|
|
149
|
+
// - Standalone commands: own every shape of `Bash(ls ...)` (any args), since
|
|
150
|
+
// a user pattern for a command we manage is redundant with our broad one.
|
|
151
|
+
// - Subcommand binaries (git, npm, ...) and exact commands: only strip the
|
|
152
|
+
// precise patterns we generate, so a user's `Bash(git push)` survives.
|
|
153
|
+
// Legacy colon shapes are handled separately by the installer.
|
|
154
|
+
function isClayManagedBashPattern(p) {
|
|
155
|
+
if (typeof p !== "string") return false;
|
|
156
|
+
if (p.indexOf("Bash(") !== 0 || p.charAt(p.length - 1) !== ")") return false;
|
|
157
|
+
if (GENERATED_SET[p]) return true;
|
|
158
|
+
var inner = p.slice(5, -1).trim();
|
|
159
|
+
if (!inner) return false;
|
|
160
|
+
var word = inner.split(/[\s:]/)[0];
|
|
161
|
+
return STANDALONE_SET[word] === true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = {
|
|
165
|
+
STANDALONE: STANDALONE,
|
|
166
|
+
SUBCOMMAND: SUBCOMMAND,
|
|
167
|
+
EXACT: EXACT,
|
|
168
|
+
isSafeBashSegment: isSafeBashSegment,
|
|
169
|
+
buildClayBashAllowPatterns: buildClayBashAllowPatterns,
|
|
170
|
+
isClayManagedBashPattern: isClayManagedBashPattern,
|
|
171
|
+
};
|
package/lib/sdk-bridge.js
CHANGED
|
@@ -6,6 +6,7 @@ var execFileSync = require("child_process").execFileSync;
|
|
|
6
6
|
var usersModule = require("./users");
|
|
7
7
|
var { getCodexConfig } = require("./codex-defaults");
|
|
8
8
|
var { splitShellSegments, attachSkillDiscovery } = require("./sdk-skill-discovery");
|
|
9
|
+
var { isSafeBashSegment } = require("./safe-bash-commands");
|
|
9
10
|
var { createMessageQueue } = require("./sdk-message-queue");
|
|
10
11
|
var { attachMessageProcessor } = require("./sdk-message-processor");
|
|
11
12
|
|
|
@@ -516,61 +517,15 @@ function createSDKBridge(opts) {
|
|
|
516
517
|
// Read/Glob/Grep built-in tools which are already auto-approved.
|
|
517
518
|
if (toolName === "Bash" && input && input.command) {
|
|
518
519
|
var cmd = input.command.trim();
|
|
519
|
-
var safeBashCommands = {
|
|
520
|
-
// Navigation (harmless on its own, checked in compound commands below)
|
|
521
|
-
cd: true, pushd: true, popd: true,
|
|
522
|
-
// File/dir inspection
|
|
523
|
-
ls: true, cat: true, head: true, tail: true, wc: true, file: true,
|
|
524
|
-
stat: true, find: true, tree: true, du: true, df: true,
|
|
525
|
-
readlink: true, realpath: true, basename: true, dirname: true,
|
|
526
|
-
// Search
|
|
527
|
-
grep: true, rg: true, ag: true, ack: true, fgrep: true, egrep: true,
|
|
528
|
-
// Lookup
|
|
529
|
-
which: true, type: true, whereis: true, command: true, hash: true,
|
|
530
|
-
// Environment/system info
|
|
531
|
-
echo: true, printf: true, env: true, printenv: true, pwd: true,
|
|
532
|
-
whoami: true, id: true, groups: true,
|
|
533
|
-
date: true, uname: true, hostname: true, uptime: true, arch: true,
|
|
534
|
-
nproc: true, free: true, lsb_release: true, sw_vers: true,
|
|
535
|
-
locale: true, timedatectl: true,
|
|
536
|
-
// Version checks (--version only, but first-word check is sufficient
|
|
537
|
-
// since these never take destructive subcommands as first arg)
|
|
538
|
-
git: true, node: true, npm: true, npx: true, python: true, python3: true, pip: true,
|
|
539
|
-
dotnet: true, ruby: true, java: true, javac: true,
|
|
540
|
-
rustc: true, cargo: true, gcc: true, clang: true, cmake: true,
|
|
541
|
-
go: true, deno: true, bun: true,
|
|
542
|
-
// Text processing (pure stdin/stdout, no side effects)
|
|
543
|
-
jq: true, yq: true, sort: true, uniq: true, cut: true, tr: true,
|
|
544
|
-
awk: true, sed: true, paste: true, column: true, fold: true,
|
|
545
|
-
rev: true, tac: true, nl: true, expand: true, unexpand: true,
|
|
546
|
-
fmt: true, pr: true, csplit: true, comm: true, join: true,
|
|
547
|
-
// Comparison/hashing
|
|
548
|
-
diff: true, cmp: true, md5sum: true, sha256sum: true, sha1sum: true,
|
|
549
|
-
shasum: true, cksum: true, sum: true, b2sum: true, base64: true,
|
|
550
|
-
xxd: true, od: true, hexdump: true,
|
|
551
|
-
// Misc read-only
|
|
552
|
-
test: true, true: true, false: true, seq: true, yes: true,
|
|
553
|
-
sleep: true, tee: true, xargs: true, time: true,
|
|
554
|
-
man: true, help: true, info: true, apropos: true,
|
|
555
|
-
cal: true, bc: true, expr: true, factor: true,
|
|
556
|
-
lsof: true, ps: true, top: true, htop: true, pgrep: true,
|
|
557
|
-
netstat: true, ss: true, ifconfig: true, ip: true, dig: true,
|
|
558
|
-
nslookup: true, host: true, ping: true, traceroute: true,
|
|
559
|
-
curl: true, wget: true, http: true,
|
|
560
|
-
};
|
|
561
520
|
// Split compound commands on operators (&&, ||, ;, |) while respecting
|
|
562
|
-
// quoted strings and subshells so
|
|
521
|
+
// quoted strings and subshells (so grep -E "(a|b)" is not split), then
|
|
522
|
+
// require every segment to pass the shared safe-command policy. The
|
|
523
|
+
// policy lives in safe-bash-commands.js so the TUI allow-list
|
|
524
|
+
// (claude-hook-installer.js) stays in lockstep with this path.
|
|
563
525
|
var segments = splitShellSegments(cmd);
|
|
564
526
|
var allSafe = true;
|
|
565
527
|
for (var si = 0; si < segments.length; si++) {
|
|
566
|
-
|
|
567
|
-
if (!seg) continue;
|
|
568
|
-
// Strip leading env assignments (FOO=bar cmd) and sudo
|
|
569
|
-
var firstWord = seg.replace(/^(?:\w+=\S*\s+)*/, "").split(/\s/)[0];
|
|
570
|
-
if (firstWord === "sudo") {
|
|
571
|
-
firstWord = seg.replace(/^(?:\w+=\S*\s+)*sudo\s+(?:-\S+\s+)*/, "").split(/\s/)[0];
|
|
572
|
-
}
|
|
573
|
-
if (!safeBashCommands[firstWord]) { allSafe = false; break; }
|
|
528
|
+
if (!isSafeBashSegment(segments[si])) { allSafe = false; break; }
|
|
574
529
|
}
|
|
575
530
|
if (allSafe) {
|
|
576
531
|
return { behavior: "allow", updatedInput: input };
|
package/lib/sessions.js
CHANGED
|
@@ -581,7 +581,7 @@ function createSessionManager(opts) {
|
|
|
581
581
|
var _capsByVendor = capabilitiesByVendor || {};
|
|
582
582
|
var _sessionVendor = session.vendor || defaultVendor || "claude";
|
|
583
583
|
var _vendorCaps = _capsByVendor[_sessionVendor] || {};
|
|
584
|
-
_send({ type: "session_switched", id: localId, cliSessionId: session.cliSessionId || null, loop: session.loop || null, vendor: session.vendor || null, hasHistory: (session.history && session.history.length > 0), capabilities: _vendorCaps, isProcessing: !!session.isProcessing, mode: session.mode || "gui", terminalId: typeof session.terminalId === "number" ? session.terminalId : null, runtimeMode: session.runtimeMode || null, runtimeTerminalId: typeof session.runtimeTerminalId === "number" ? session.runtimeTerminalId : null });
|
|
584
|
+
_send({ type: "session_switched", id: localId, cliSessionId: session.cliSessionId || null, loop: session.loop || null, vendor: session.vendor || null, hasHistory: (session.history && session.history.length > 0), capabilities: _vendorCaps, isProcessing: !!session.isProcessing, mode: session.mode || "gui", terminalId: typeof session.terminalId === "number" ? session.terminalId : null, runtimeMode: session.runtimeMode || null, runtimeTerminalId: typeof session.runtimeTerminalId === "number" ? session.runtimeTerminalId : null, tuiSuspended: !!session.tuiSuspended });
|
|
585
585
|
// Send vendor-specific slash commands
|
|
586
586
|
var _vendorCmds = slashCommandsByVendor[_sessionVendor] || slashCommands || [];
|
|
587
587
|
_send({ type: "slash_commands", commands: _vendorCmds, vendor: _sessionVendor });
|