hjworktree-cli 2.1.0 → 2.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hjworktree-cli",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Web-based git worktree parallel AI coding agent runner",
5
5
  "type": "module",
6
6
  "main": "dist/server/index.js",
@@ -125,6 +125,13 @@ export function apiRouter(cwd: string): Router {
125
125
  return;
126
126
  }
127
127
 
128
+ // SECURITY: Never delete main worktree
129
+ if (worktree.isMainWorktree) {
130
+ console.warn(`[SECURITY] API blocked attempt to delete main worktree: ${name}`);
131
+ res.status(403).json({ error: 'Cannot delete main worktree' });
132
+ return;
133
+ }
134
+
128
135
  await worktreeService.removeWorktree(worktree.path);
129
136
  res.json({ success: true });
130
137
  } catch (error) {
@@ -1,6 +1,7 @@
1
1
  import { simpleGit, SimpleGit } from 'simple-git';
2
2
  import path from 'path';
3
3
  import fs from 'fs/promises';
4
+ import { realpathSync, statSync } from 'fs';
4
5
  import type { Worktree } from '../../shared/types/index.js';
5
6
 
6
7
  export class WorktreeService {
@@ -13,11 +14,56 @@ export class WorktreeService {
13
14
  this.git = simpleGit(cwd);
14
15
  }
15
16
 
17
+ /**
18
+ * Safely resolves a path to its real path, handling symlinks.
19
+ * Returns null if path cannot be resolved.
20
+ */
21
+ private safeRealpath(targetPath: string): string | null {
22
+ try {
23
+ return realpathSync(targetPath);
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Checks if a path is a linked worktree (has .git file) vs main repo (has .git directory).
31
+ * Linked worktrees have a .git FILE that points to the main .git directory.
32
+ * Main repo has a .git DIRECTORY.
33
+ */
34
+ private isLinkedWorktree(worktreePath: string): boolean {
35
+ const gitPath = path.join(worktreePath, '.git');
36
+ try {
37
+ const stat = statSync(gitPath);
38
+ // .git is a directory = main repository
39
+ // .git is a file = linked worktree
40
+ return !stat.isDirectory();
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Checks if the given path is the main repository.
48
+ */
49
+ private isMainRepository(targetPath: string): boolean {
50
+ const resolvedTarget = this.safeRealpath(targetPath);
51
+ const resolvedRoot = this.safeRealpath(this.rootDir);
52
+
53
+ if (!resolvedTarget || !resolvedRoot) {
54
+ // If we can't resolve paths, assume it might be main repo (fail safe)
55
+ return true;
56
+ }
57
+
58
+ return resolvedTarget === resolvedRoot;
59
+ }
60
+
16
61
  async listWorktrees(): Promise<Worktree[]> {
17
62
  const result = await this.git.raw(['worktree', 'list', '--porcelain']);
18
63
  const worktrees: Worktree[] = [];
19
64
 
20
65
  const entries = result.trim().split('\n\n').filter(Boolean);
66
+ const resolvedRootDir = this.safeRealpath(this.rootDir);
21
67
 
22
68
  for (const entry of entries) {
23
69
  const lines = entry.split('\n');
@@ -26,10 +72,17 @@ export class WorktreeService {
26
72
  const branch = branchLine?.replace('branch refs/heads/', '');
27
73
 
28
74
  if (worktreePath) {
75
+ // Determine if this is the main worktree using realpath comparison
76
+ const resolvedWorktreePath = this.safeRealpath(worktreePath);
77
+ const isMainWorktree = resolvedWorktreePath !== null &&
78
+ resolvedRootDir !== null &&
79
+ resolvedWorktreePath === resolvedRootDir;
80
+
29
81
  worktrees.push({
30
82
  path: worktreePath,
31
83
  branch: branch || 'detached',
32
84
  name: path.basename(worktreePath),
85
+ isMainWorktree,
33
86
  });
34
87
  }
35
88
  }
@@ -72,6 +125,7 @@ export class WorktreeService {
72
125
  path: worktreePath,
73
126
  branch: newBranchName,
74
127
  name: worktreeName,
128
+ isMainWorktree: false, // Created worktrees are always linked, not main
75
129
  };
76
130
  }
77
131
 
@@ -103,28 +157,66 @@ export class WorktreeService {
103
157
  }
104
158
 
105
159
  async removeWorktree(worktreePath: string): Promise<void> {
160
+ // SECURITY GUARD 1: Use realpathSync for symlink resolution
161
+ const resolvedWorktreePath = this.safeRealpath(worktreePath);
162
+ const resolvedRootDir = this.safeRealpath(this.rootDir);
163
+
164
+ // If we cannot resolve paths, abort for safety
165
+ if (!resolvedWorktreePath) {
166
+ console.warn(`[SECURITY] Cannot resolve worktree path: ${worktreePath}. Aborting deletion.`);
167
+ return;
168
+ }
169
+
170
+ if (!resolvedRootDir) {
171
+ console.warn(`[SECURITY] Cannot resolve root directory path. Aborting deletion.`);
172
+ return;
173
+ }
174
+
175
+ // SECURITY GUARD 2: Check exact match with main repo
176
+ if (resolvedWorktreePath === resolvedRootDir) {
177
+ console.warn(`[SECURITY] Attempted to remove main repository: ${worktreePath}. Ignoring.`);
178
+ return;
179
+ }
180
+
181
+ // SECURITY GUARD 3: Check if path is inside main repo
182
+ if (resolvedWorktreePath.startsWith(resolvedRootDir + path.sep)) {
183
+ console.warn(`[SECURITY] Attempted to remove path inside main repository: ${worktreePath}. Ignoring.`);
184
+ return;
185
+ }
186
+
187
+ // SECURITY GUARD 4: Check if main repo is inside target path (catastrophic prevention)
188
+ if (resolvedRootDir.startsWith(resolvedWorktreePath + path.sep)) {
189
+ console.warn(`[SECURITY] Attempted to remove parent of main repository: ${worktreePath}. Ignoring.`);
190
+ return;
191
+ }
192
+
193
+ // SECURITY GUARD 5: Verify it's actually a linked worktree (has .git FILE, not directory)
194
+ const isLinked = this.isLinkedWorktree(worktreePath);
195
+ if (!isLinked) {
196
+ console.warn(`[SECURITY] Path is not a linked worktree (missing .git file or has .git directory): ${worktreePath}. Ignoring.`);
197
+ return;
198
+ }
199
+
106
200
  const branchName = path.basename(worktreePath);
107
201
 
202
+ // CRITICAL: Only use git worktree remove - NEVER use fs.rm() as fallback
108
203
  try {
109
- // 1. Force remove worktree
110
204
  await this.git.raw(['worktree', 'remove', worktreePath, '--force']);
111
- } catch {
112
- // 2. If worktree remove fails, try to remove directory manually
113
- try {
114
- await fs.rm(worktreePath, { recursive: true, force: true });
115
- } catch {
116
- // Ignore errors
117
- }
205
+ } catch (error) {
206
+ // Log the error but DO NOT fall back to fs.rm
207
+ console.error(`[SECURITY] Failed to remove worktree via git: ${worktreePath}`, error);
208
+ // Propagate error instead of dangerous fallback
209
+ throw new Error(`Failed to remove worktree: ${error instanceof Error ? error.message : 'Unknown error'}`);
118
210
  }
119
211
 
120
- // 3. CRITICAL: Prune orphan worktree references
212
+ // Prune orphan worktree references
121
213
  try {
122
214
  await this.git.raw(['worktree', 'prune']);
123
215
  } catch {
124
- // Ignore prune errors
216
+ // Ignore prune errors - non-critical
125
217
  }
126
218
 
127
- // 4. Now we can safely delete the branch
219
+ // Delete the branch
128
220
  try {
129
221
  await this.git.branch(['-D', branchName]);
130
222
  } catch {
@@ -137,8 +229,25 @@ export class WorktreeService {
137
229
  async removeAllWorktrees(): Promise<void> {
138
230
  const worktrees = await this.listWorktrees();
139
231
 
140
- // Filter out the main worktree (usually the first one / the main repo)
141
- const additionalWorktrees = worktrees.filter(wt => wt.path !== this.rootDir);
232
+ // Filter out the main worktree using the isMainWorktree flag and path comparison
233
+ const additionalWorktrees = worktrees.filter(wt => {
234
+ // Primary check: use isMainWorktree flag
235
+ if (wt.isMainWorktree) {
236
+ console.log(`[SECURITY] Skipping main worktree in removeAll: ${wt.path}`);
237
+ return false;
238
+ }
239
+
240
+ // Secondary check: realpath comparison as defense in depth
241
+ const resolvedPath = this.safeRealpath(wt.path);
242
+ const resolvedRoot = this.safeRealpath(this.rootDir);
243
+
244
+ if (resolvedPath && resolvedRoot && resolvedPath === resolvedRoot) {
245
+ console.warn(`[SECURITY] Detected main worktree via realpath (flag was false): ${wt.path}`);
246
+ return false;
247
+ }
248
+
249
+ return true;
250
+ });
142
251
 
143
252
  for (const worktree of additionalWorktrees) {
144
253
  try {
@@ -187,6 +296,13 @@ export class WorktreeService {
187
296
  continue;
188
297
  }
189
298
 
299
+ // SECURITY: Never delete main worktree
300
+ if (worktree.isMainWorktree) {
301
+ console.warn(`[SECURITY] Blocked attempt to delete main worktree by name: ${name}`);
302
+ results.failed.push({ name, error: 'Cannot delete main worktree' });
303
+ continue;
304
+ }
305
+
190
306
  try {
191
307
  await this.removeWorktree(worktree.path);
192
308
  results.deleted.push(name);
@@ -1,6 +1,6 @@
1
1
  import { Server, Socket } from 'socket.io';
2
2
  import * as pty from 'node-pty';
3
- import type { IPty } from 'node-pty';
3
+ import type { IPty, IPtyForkOptions } from 'node-pty';
4
4
  import type {
5
5
  TerminalCreateData,
6
6
  TerminalInputData,
@@ -9,6 +9,80 @@ import type {
9
9
  AgentId
10
10
  } from '../shared/types/index.js';
11
11
  import { AI_AGENTS } from '../shared/constants.js';
12
+ import fs from 'fs';
13
+ import { promisify } from 'util';
14
+
15
+ const access = promisify(fs.access);
16
+
17
+ // Validation utilities
18
+ async function validatePath(pathToCheck: string): Promise<{ valid: boolean; error?: string }> {
19
+ try {
20
+ await access(pathToCheck, fs.constants.R_OK | fs.constants.X_OK);
21
+ return { valid: true };
22
+ } catch (error) {
23
+ return {
24
+ valid: false,
25
+ error: error instanceof Error ? error.message : 'Path not accessible'
26
+ };
27
+ }
28
+ }
29
+
30
+ async function validateShell(shell: string): Promise<{ valid: boolean; error?: string }> {
31
+ if (process.platform === 'win32') {
32
+ return { valid: true };
33
+ }
34
+ try {
35
+ await access(shell, fs.constants.X_OK);
36
+ return { valid: true };
37
+ } catch (error) {
38
+ return {
39
+ valid: false,
40
+ error: `Shell not executable: ${shell}`
41
+ };
42
+ }
43
+ }
44
+
45
+ // PTY spawn with retry logic
46
+ async function spawnWithRetry(
47
+ shell: string,
48
+ args: string[],
49
+ options: IPtyForkOptions,
50
+ maxRetries: number = 3
51
+ ): Promise<IPty> {
52
+ let lastError: Error | null = null;
53
+
54
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
55
+ try {
56
+ const ptyProcess = pty.spawn(shell, args, options);
57
+ return ptyProcess;
58
+ } catch (error) {
59
+ lastError = error instanceof Error ? error : new Error(String(error));
60
+ console.error(`[PTY] Spawn attempt ${attempt + 1}/${maxRetries} failed:`, lastError.message);
61
+
62
+ if (attempt < maxRetries - 1) {
63
+ const delay = Math.pow(2, attempt) * 100;
64
+ await new Promise(resolve => setTimeout(resolve, delay));
65
+ }
66
+ }
67
+ }
68
+
69
+ throw lastError || new Error('Failed to spawn PTY after retries');
70
+ }
71
+
72
+ // Spawn interval control to prevent resource contention
73
+ let lastSpawnTime = 0;
74
+ const MIN_SPAWN_INTERVAL = 150;
75
+
76
+ async function waitForSpawnInterval(): Promise<void> {
77
+ const now = Date.now();
78
+ const elapsed = now - lastSpawnTime;
79
+
80
+ if (elapsed < MIN_SPAWN_INTERVAL) {
81
+ await new Promise(resolve => setTimeout(resolve, MIN_SPAWN_INTERVAL - elapsed));
82
+ }
83
+
84
+ lastSpawnTime = Date.now();
85
+ }
12
86
 
13
87
  interface TerminalSession {
14
88
  pty: IPty;
@@ -42,7 +116,22 @@ export function setupSocketHandlers(io: Server, cwd: string) {
42
116
  const shell = getShell();
43
117
  const agentCommand = getAgentCommand(agentType);
44
118
 
45
- const ptyProcess = pty.spawn(shell, [], {
119
+ // Validate path before spawning
120
+ const pathValidation = await validatePath(worktreePath);
121
+ if (!pathValidation.valid) {
122
+ throw new Error(`Invalid worktree path: ${pathValidation.error}`);
123
+ }
124
+
125
+ // Validate shell before spawning
126
+ const shellValidation = await validateShell(shell);
127
+ if (!shellValidation.valid) {
128
+ throw new Error(`Invalid shell: ${shellValidation.error}`);
129
+ }
130
+
131
+ // Wait for spawn interval to prevent resource contention
132
+ await waitForSpawnInterval();
133
+
134
+ const ptyProcess = await spawnWithRetry(shell, [], {
46
135
  name: 'xterm-256color',
47
136
  cols: 120,
48
137
  rows: 30,
@@ -51,6 +51,7 @@ export interface Worktree {
51
51
  path: string;
52
52
  branch: string;
53
53
  name: string;
54
+ isMainWorktree: boolean;
54
55
  }
55
56
 
56
57
  // Socket.IO event types