claude-code-kanban 4.17.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.
- package/lib/contain.js +78 -0
- package/lib/inline-assets.js +238 -0
- package/lib/net-guard.js +267 -0
- package/lib/open-editor.js +267 -0
- package/lib/parsers.js +8 -1
- package/package.json +3 -3
- package/public/app.js +164 -76
- package/public/index.html +19 -11
- package/public/style.css +32 -2
- package/server.js +135 -52
|
@@ -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.
|
|
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": "
|
|
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
|
|
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"
|