claude-code-kanban 4.16.0 → 4.18.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.
@@ -0,0 +1,267 @@
1
+ 'use strict';
2
+
3
+ // Launch a GUI editor without ever handing a string to a shell.
4
+ //
5
+ // The naive fix for command injection here — dropping `shell: true` — does not
6
+ // work on Windows, and fails in two different ways:
7
+ //
8
+ // * `spawn('code', args)` throws ENOENT, because the launcher on PATH is
9
+ // `code.cmd` and Node does not consult PATHEXT.
10
+ // * Resolving to `code.cmd` and spawning that throws EINVAL, because since the
11
+ // CVE-2024-27980 fix (Node 18.20.2 / 20.12.2 / 21.7.3+) a .bat/.cmd target
12
+ // may only be spawned with `shell: true` — the very thing we are removing.
13
+ //
14
+ // So on Windows we resolve the real .exe sitting behind the .cmd shim and spawn
15
+ // that directly. Only when no such .exe exists (npm-style shims, which are node
16
+ // scripts with no binary twin) do we fall back to cmd.exe, and that path applies
17
+ // a character allowlist so the quoting cannot be broken out of.
18
+ //
19
+ // CANONICAL COPY: scripts/security-lib/open-editor.js in the claude-code-hub repo.
20
+ // Duplicated verbatim into cck/, marketplace/ and memory/ — they are independent
21
+ // npm packages and cannot import from each other. Run scripts/sync-security-lib.sh
22
+ // after editing.
23
+
24
+ const { spawn } = require('child_process');
25
+ const path = require('path');
26
+ const fs = require('fs');
27
+
28
+ const IS_WIN = process.platform === 'win32';
29
+
30
+ // Editors whose CLI understands `-n` (open in a new window). Passing it to
31
+ // vim/nano/emacs would be wrong, so it is only injected for this family.
32
+ const VSCODE_FAMILY = new Set([
33
+ 'code', 'code-insiders', 'codium', 'vscodium', 'cursor', 'windsurf', 'positron', 'trae',
34
+ ]);
35
+
36
+ // Characters that survive cmd.exe's double quotes and would let an argument
37
+ // escape it. Inside "..." cmd treats & | < > ^ ( ) as literal, so only these
38
+ // matter: '"' ends the quoted run, '%' triggers variable expansion whose value
39
+ // could itself contain a quote. Neither is legal in an NTFS path anyway.
40
+ const WIN_CMD_FORBIDDEN = /["%\r\n\u0000]/;
41
+
42
+ function fail(message, status) {
43
+ return Object.assign(new Error(message), { status });
44
+ }
45
+
46
+ // Split an EDITOR value into argv. Handles `EDITOR="code -w"` and quoted paths
47
+ // such as `"C:\Program Files\App\app.exe" --flag`. No expansion, no globbing —
48
+ // EDITOR is operator-supplied, not request-supplied.
49
+ function splitCommandLine(str) {
50
+ if (typeof str !== 'string') return []; // String(undefined) would yield an "undefined" argv entry
51
+ const out = [];
52
+ let cur = '';
53
+ let quote = null;
54
+ let quoted = false;
55
+ for (const ch of String(str)) {
56
+ if (quote) {
57
+ if (ch === quote) quote = null;
58
+ else cur += ch;
59
+ continue;
60
+ }
61
+ if (ch === '"' || ch === "'") { quote = ch; quoted = true; continue; }
62
+ if (/\s/.test(ch)) {
63
+ if (cur || quoted) { out.push(cur); cur = ''; quoted = false; }
64
+ continue;
65
+ }
66
+ cur += ch;
67
+ }
68
+ if (cur || quoted) out.push(cur);
69
+ return out;
70
+ }
71
+
72
+ // The PATH walk below stats PATH.length × PATHEXT.length candidates — on a
73
+ // typical Windows box ~1200 stats, several milliseconds. The answer cannot change
74
+ // within a process unless PATH itself does, so it is memoized on both.
75
+ const whichCache = new Map();
76
+
77
+ // which(1). On Windows this walks PATHEXT so we learn the resolved extension —
78
+ // that .cmd-vs-.exe distinction is what selects the launch strategy below.
79
+ function whichSync(cmd) {
80
+ if (!cmd) return null;
81
+ if (cmd.includes('/') || cmd.includes('\\')) {
82
+ const abs = path.resolve(cmd);
83
+ try { return fs.statSync(abs).isFile() ? abs : null; } catch { return null; }
84
+ }
85
+ const key = `${process.env.PATH || ''}\u0000${process.env.PATHEXT || ''}\u0000${cmd}`;
86
+ if (whichCache.has(key)) return whichCache.get(key);
87
+ const found = whichUncached(cmd);
88
+ whichCache.set(key, found);
89
+ return found;
90
+ }
91
+
92
+ function whichUncached(cmd) {
93
+ const dirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
94
+ const exts = IS_WIN
95
+ ? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
96
+ : [''];
97
+ for (const dir of dirs) {
98
+ for (const ext of exts) {
99
+ const candidate = path.join(dir, cmd + ext);
100
+ try { if (fs.statSync(candidate).isFile()) return candidate; } catch { /* next */ }
101
+ }
102
+ }
103
+ return null;
104
+ }
105
+
106
+ // A GUI editor's .cmd shim normally lives at <install>/bin/foo.cmd next to
107
+ // <install>/Foo.exe. Returns that .exe so we can skip the shell entirely.
108
+ // npm-installed shims have no such twin and correctly return null here.
109
+ function exeBehindShim(shimPath) {
110
+ const bin = path.dirname(shimPath);
111
+ const root = path.dirname(bin);
112
+ const stem = path.basename(shimPath, path.extname(shimPath));
113
+ const candidates = [
114
+ path.join(root, `${stem}.exe`),
115
+ path.join(bin, `${stem}.exe`),
116
+ path.join(root, 'Code.exe'),
117
+ path.join(root, 'Cursor.exe'),
118
+ path.join(root, 'Windsurf.exe'),
119
+ path.join(root, 'VSCodium.exe'),
120
+ path.join(root, 'Positron.exe'),
121
+ ];
122
+ for (const candidate of candidates) {
123
+ try { if (fs.statSync(candidate).isFile()) return candidate; } catch { /* next */ }
124
+ }
125
+ return null;
126
+ }
127
+
128
+ function launchViaCmd(exe, args) {
129
+ for (const arg of [exe, ...args]) {
130
+ if (WIN_CMD_FORBIDDEN.test(arg)) {
131
+ throw fail('Path contains a character that cannot be passed safely to this editor', 400);
132
+ }
133
+ }
134
+ const line = [exe, ...args].map((a) => `"${a}"`).join(' ');
135
+ return spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', `"${line}"`], {
136
+ windowsVerbatimArguments: true,
137
+ windowsHide: true,
138
+ stdio: 'ignore',
139
+ detached: true,
140
+ });
141
+ }
142
+
143
+ let cachedEditor;
144
+
145
+ function resolveEditor() {
146
+ const spec = (process.env.EDITOR || '').trim() || 'code';
147
+ if (cachedEditor && cachedEditor.spec === spec) return cachedEditor;
148
+ const parts = splitCommandLine(spec);
149
+ const cmd = parts[0];
150
+ if (!cmd) throw fail('No editor configured (set EDITOR)', 500);
151
+ const resolved = whichSync(cmd);
152
+ if (!resolved) throw fail(`Editor not found on PATH: ${cmd}`, 500);
153
+ cachedEditor = { spec, resolved, preArgs: parts.slice(1) };
154
+ return cachedEditor;
155
+ }
156
+
157
+ /**
158
+ * Validate a request-supplied path before it becomes an editor argument, and
159
+ * return its resolved absolute form. Lives here rather than in each server so the
160
+ * rule cannot drift between the apps that share this helper.
161
+ *
162
+ * Deliberately not a containment check: opening an arbitrary project directory is
163
+ * the feature these endpoints exist for. The network boundary is the control.
164
+ *
165
+ * @param {unknown} value
166
+ * @param {string} label used in the error message
167
+ * @returns {string} resolved absolute path
168
+ */
169
+ function assertOpenTarget(value, label = 'path') {
170
+ if (typeof value !== 'string' || !value.trim()) throw fail(`${label} must be a non-empty string`, 400);
171
+ if (/[\r\n\u0000]/.test(value)) throw fail(`${label} contains invalid characters`, 400);
172
+ const resolved = path.resolve(value);
173
+ if (!fs.existsSync(resolved)) throw fail(`${label} does not exist`, 404);
174
+ return resolved;
175
+ }
176
+
177
+ /**
178
+ * Open one or more already-validated absolute paths in the user's editor.
179
+ * @param {string[]} targets
180
+ * @param {{newWindow?: boolean}} [opts]
181
+ */
182
+ function openInEditor(targets, opts = {}) {
183
+ const list = (Array.isArray(targets) ? targets : [targets]).filter(Boolean);
184
+ if (!list.length) throw fail('Nothing to open', 400);
185
+
186
+ const { resolved, preArgs } = resolveEditor();
187
+ const stem = path.basename(resolved, path.extname(resolved)).toLowerCase();
188
+
189
+ const flags = [...preArgs];
190
+ if (opts.newWindow !== false && VSCODE_FAMILY.has(stem) && !flags.includes('-n')) {
191
+ flags.unshift('-n');
192
+ }
193
+ const argv = [...flags, ...list];
194
+
195
+ const ext = path.extname(resolved).toLowerCase();
196
+ if (IS_WIN && (ext === '.cmd' || ext === '.bat')) {
197
+ const exe = exeBehindShim(resolved);
198
+ if (exe) return spawn(exe, argv, { stdio: 'ignore', detached: true, windowsHide: true }).unref();
199
+ return launchViaCmd(resolved, argv).unref();
200
+ }
201
+
202
+ return spawn(resolved, argv, { stdio: 'ignore', detached: true }).unref();
203
+ }
204
+
205
+ /**
206
+ * Run a command without a shell and capture its output. Same Windows .cmd
207
+ * strategy as openInEditor: prefer the real .exe, fall back to an explicitly
208
+ * quoted cmd.exe line guarded by the character allowlist. Callers must still
209
+ * validate every argument — this stops shell metacharacters, not bad flags.
210
+ * @param {string} cmd
211
+ * @param {string[]} args
212
+ * @param {{cwd?: string, timeout?: number}} [opts]
213
+ * @returns {Promise<{stdout: string, stderr: string}>}
214
+ */
215
+ function execNoShell(cmd, args, opts = {}) {
216
+ return new Promise((resolve, reject) => {
217
+ const resolved = whichSync(cmd);
218
+ if (!resolved) return reject(fail(`Command not found on PATH: ${cmd}`, 500));
219
+
220
+ let file = resolved;
221
+ let argv = args;
222
+ const spawnOpts = {
223
+ cwd: opts.cwd || undefined,
224
+ timeout: opts.timeout,
225
+ windowsHide: true,
226
+ stdio: ['ignore', 'pipe', 'pipe'],
227
+ };
228
+
229
+ const ext = path.extname(resolved).toLowerCase();
230
+ if (IS_WIN && (ext === '.cmd' || ext === '.bat')) {
231
+ const exe = exeBehindShim(resolved);
232
+ if (exe) {
233
+ file = exe;
234
+ } else {
235
+ for (const arg of [resolved, ...args]) {
236
+ if (WIN_CMD_FORBIDDEN.test(String(arg))) {
237
+ return reject(fail('Argument contains a character that cannot be passed safely', 400));
238
+ }
239
+ }
240
+ const line = [resolved, ...args].map((a) => `"${a}"`).join(' ');
241
+ file = process.env.ComSpec || 'cmd.exe';
242
+ argv = ['/d', '/s', '/c', `"${line}"`];
243
+ spawnOpts.windowsVerbatimArguments = true;
244
+ }
245
+ }
246
+
247
+ let child;
248
+ try {
249
+ child = spawn(file, argv, spawnOpts);
250
+ } catch (err) {
251
+ return reject(err);
252
+ }
253
+
254
+ let stdout = '';
255
+ let stderr = '';
256
+ child.stdout.on('data', (d) => { stdout += d; });
257
+ child.stderr.on('data', (d) => { stderr += d; });
258
+ child.on('error', reject);
259
+ child.on('close', (code, signal) => {
260
+ if (signal) return reject(new Error(`Command terminated by ${signal}: ${stderr.trim()}`));
261
+ if (code !== 0) return reject(new Error(stderr.trim() || `Command exited with code ${code}`));
262
+ resolve({ stdout, stderr });
263
+ });
264
+ });
265
+ }
266
+
267
+ module.exports = { assertOpenTarget, openInEditor, execNoShell, whichSync, splitCommandLine, exeBehindShim };
package/lib/parsers.js CHANGED
@@ -305,7 +305,7 @@ function extractGoalFromLine(data) {
305
305
  // live branch must resolve it from cwd separately. Cache is reset on inode
306
306
  // change or truncation (size < scannedUpTo).
307
307
  function readSessionInfoFromJsonl(jsonlPath) {
308
- const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null, logicalParentUuid: null, goal: null };
308
+ const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null, logicalParentUuid: null, compactBoundaryUuid: null, goal: null };
309
309
  let stat;
310
310
  let fd;
311
311
  try {
@@ -326,6 +326,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
326
326
  cwd: cached.cwd,
327
327
  gitBranch: cached.gitBranch,
328
328
  logicalParentUuid: cached.logicalParentUuid || null,
329
+ compactBoundaryUuid: cached.compactBoundaryUuid || null,
329
330
  goal: cached.goal || null,
330
331
  customTitle: readCustomTitle(jsonlPath, stat)
331
332
  };
@@ -337,6 +338,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
337
338
  result.cwd = cached.cwd;
338
339
  result.gitBranch = cached.gitBranch;
339
340
  result.logicalParentUuid = cached.logicalParentUuid || null;
341
+ result.compactBoundaryUuid = cached.compactBoundaryUuid || null;
340
342
  result.goal = cached.goal || null;
341
343
  }
342
344
 
@@ -352,6 +354,10 @@ function readSessionInfoFromJsonl(jsonlPath) {
352
354
  if (data.gitBranch) result.gitBranch = data.gitBranch;
353
355
  if (data.subtype === 'compact_boundary' && data.logicalParentUuid && !result.logicalParentUuid) {
354
356
  result.logicalParentUuid = data.logicalParentUuid;
357
+ // The boundary record's own uuid: a fork copies it verbatim from the
358
+ // parent, a real compact continuation writes a fresh one. Callers use
359
+ // its presence in the parent JSONL to tell the two apart.
360
+ result.compactBoundaryUuid = data.uuid || null;
355
361
  }
356
362
  // Forward → last wins.
357
363
  const goalUpdate = extractGoalFromLine(data);
@@ -456,6 +462,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
456
462
  gitBranch: result.gitBranch,
457
463
  cwd: result.cwd,
458
464
  logicalParentUuid: result.logicalParentUuid,
465
+ compactBoundaryUuid: result.compactBoundaryUuid,
459
466
  goal: result.goal
460
467
  });
461
468
  if (sessionInfoCache.size > SESSION_INFO_CACHE_MAX) {
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "4.16.0",
3
+ "version": "4.18.0",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "bin": {
7
- "claude-code-kanban": "./server.js"
7
+ "claude-code-kanban": "server.js"
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node server.js",
11
11
  "dev": "node --watch server.js",
12
- "test": "node --test test/contracts.test.js",
12
+ "test": "node --test test/*.test.js",
13
13
  "test:hooks": "bash tests/test-agent-spy.sh",
14
14
  "validate:schemas": "node test/validate-live-schemas.js",
15
15
  "prepare": "husky"