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.
@@ -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: split button. Default click starts a TUI session (the path
354
- // most users want post 2026-06-15 Agent SDK billing split). The small
355
- // chevron on the right opens a dropdown that lets the user explicitly
356
- // pick TUI or GUI for this one session, bypassing the saved pref.
357
- var claudeWrap = document.createElement("div");
358
- claudeWrap.className = "session-top-action session-top-action-split";
359
-
360
- var claudeMain = document.createElement("button");
361
- claudeMain.className = "session-top-action-main";
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", mode: "tui" }));
309
+ getWs().send(JSON.stringify({ type: "new_session", vendor: "claude" }));
368
310
  }
369
311
  });
370
- claudeWrap.appendChild(claudeMain);
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">&#9650;</button>' +
18
+ '<button class="term-key term-key-arrow" data-key="down">&#9660;</button>' +
19
+ '<button class="term-key term-key-arrow" data-key="left">&#9664;</button>' +
20
+ '<button class="term-key term-key-arrow" data-key="right">&#9654;</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 ctrlActive = false;
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
- toolbar.classList.add("hidden");
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
- initToolbar(toolbar);
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 that e.g. grep -E "(a|b)" is not split
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
- var seg = segments[si].trim();
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 });