start-command 0.9.0 → 0.10.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,429 @@
1
+ /**
2
+ * User Manager for start-command
3
+ *
4
+ * Provides utilities for creating isolated users with the same
5
+ * group memberships as the current user. This enables true user
6
+ * isolation while preserving access to sudo, docker, and other
7
+ * privileged groups.
8
+ */
9
+
10
+ const { execSync, spawnSync } = require('child_process');
11
+
12
+ // Debug mode from environment
13
+ const DEBUG =
14
+ process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true';
15
+
16
+ /**
17
+ * Get the current user's username
18
+ * @returns {string} Current username
19
+ */
20
+ function getCurrentUser() {
21
+ try {
22
+ return execSync('whoami', { encoding: 'utf8' }).trim();
23
+ } catch {
24
+ return process.env.USER || process.env.USERNAME || 'unknown';
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Get the groups the current user belongs to
30
+ * @returns {string[]} Array of group names
31
+ */
32
+ function getCurrentUserGroups() {
33
+ try {
34
+ // Get groups for the current user
35
+ const output = execSync('groups', { encoding: 'utf8' }).trim();
36
+ // Output format: "user : group1 group2 group3" or "group1 group2 group3"
37
+ const parts = output.split(':');
38
+ const groupsPart = parts.length > 1 ? parts[1] : parts[0];
39
+ return groupsPart.trim().split(/\s+/).filter(Boolean);
40
+ } catch (err) {
41
+ if (DEBUG) {
42
+ console.log(`[DEBUG] Failed to get user groups: ${err.message}`);
43
+ }
44
+ return [];
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Check if a user exists on the system
50
+ * @param {string} username - Username to check
51
+ * @returns {boolean} True if user exists
52
+ */
53
+ function userExists(username) {
54
+ try {
55
+ execSync(`id ${username}`, { stdio: ['pipe', 'pipe', 'pipe'] });
56
+ return true;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Check if a group exists on the system
64
+ * @param {string} groupname - Group name to check
65
+ * @returns {boolean} True if group exists
66
+ */
67
+ function groupExists(groupname) {
68
+ try {
69
+ execSync(`getent group ${groupname}`, { stdio: ['pipe', 'pipe', 'pipe'] });
70
+ return true;
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Generate a unique username for isolation
78
+ * @param {string} [prefix='start'] - Prefix for the username
79
+ * @returns {string} Generated username
80
+ */
81
+ function generateIsolatedUsername(prefix = 'start') {
82
+ const timestamp = Date.now().toString(36);
83
+ const random = Math.random().toString(36).substring(2, 6);
84
+ // Keep username short (max 32 chars on most systems)
85
+ // and valid (only alphanumeric, hyphen, underscore)
86
+ return `${prefix}-${timestamp}${random}`.substring(0, 31);
87
+ }
88
+
89
+ /**
90
+ * Create a new user with specified groups
91
+ * Requires sudo access
92
+ *
93
+ * @param {string} username - Username to create
94
+ * @param {string[]} groups - Groups to add user to
95
+ * @param {object} options - Options
96
+ * @param {boolean} options.noLogin - If true, create user with nologin shell
97
+ * @param {string} options.homeDir - Home directory (default: /home/username)
98
+ * @returns {{success: boolean, message: string, username: string}}
99
+ */
100
+ function createUser(username, groups = [], options = {}) {
101
+ if (process.platform === 'win32') {
102
+ return {
103
+ success: false,
104
+ message: 'User creation is not supported on Windows',
105
+ username,
106
+ };
107
+ }
108
+
109
+ if (userExists(username)) {
110
+ return {
111
+ success: true,
112
+ message: `User "${username}" already exists`,
113
+ username,
114
+ alreadyExists: true,
115
+ };
116
+ }
117
+
118
+ try {
119
+ // Build useradd command
120
+ const useradd = ['sudo', '-n', 'useradd'];
121
+
122
+ // Add home directory option
123
+ if (options.homeDir) {
124
+ useradd.push('-d', options.homeDir);
125
+ }
126
+ useradd.push('-m'); // Create home directory
127
+
128
+ // Add shell option
129
+ if (options.noLogin) {
130
+ useradd.push('-s', '/usr/sbin/nologin');
131
+ } else {
132
+ useradd.push('-s', '/bin/bash');
133
+ }
134
+
135
+ // Filter groups to only existing ones
136
+ const existingGroups = groups.filter(groupExists);
137
+ if (existingGroups.length > 0) {
138
+ // Add user to groups (comma-separated)
139
+ useradd.push('-G', existingGroups.join(','));
140
+ }
141
+
142
+ // Add username
143
+ useradd.push(username);
144
+
145
+ if (DEBUG) {
146
+ console.log(`[DEBUG] Creating user: ${useradd.join(' ')}`);
147
+ console.log(`[DEBUG] Groups to add: ${existingGroups.join(', ')}`);
148
+ }
149
+
150
+ const result = spawnSync(useradd[0], useradd.slice(1), {
151
+ stdio: ['pipe', 'pipe', 'pipe'],
152
+ encoding: 'utf8',
153
+ });
154
+
155
+ if (result.status !== 0) {
156
+ const stderr = result.stderr || '';
157
+ return {
158
+ success: false,
159
+ message: `Failed to create user: ${stderr.trim() || 'Unknown error'}`,
160
+ username,
161
+ };
162
+ }
163
+
164
+ return {
165
+ success: true,
166
+ message: `Created user "${username}" with groups: ${existingGroups.join(', ') || 'none'}`,
167
+ username,
168
+ groups: existingGroups,
169
+ };
170
+ } catch (err) {
171
+ return {
172
+ success: false,
173
+ message: `Failed to create user: ${err.message}`,
174
+ username,
175
+ };
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Create an isolated user with the same groups as the current user
181
+ * @param {string} [customUsername] - Optional custom username (auto-generated if not provided)
182
+ * @param {object} options - Options
183
+ * @param {string[]} options.includeGroups - Only include these groups (default: all)
184
+ * @param {string[]} options.excludeGroups - Exclude these groups (default: none)
185
+ * @param {boolean} options.noLogin - Create with nologin shell
186
+ * @returns {{success: boolean, message: string, username: string, groups: string[]}}
187
+ */
188
+ function createIsolatedUser(customUsername, options = {}) {
189
+ const username = customUsername || generateIsolatedUsername();
190
+ let groups = getCurrentUserGroups();
191
+
192
+ // Filter groups if specified
193
+ if (options.includeGroups && options.includeGroups.length > 0) {
194
+ groups = groups.filter((g) => options.includeGroups.includes(g));
195
+ }
196
+
197
+ if (options.excludeGroups && options.excludeGroups.length > 0) {
198
+ groups = groups.filter((g) => !options.excludeGroups.includes(g));
199
+ }
200
+
201
+ // Important groups for isolation to work properly
202
+ const importantGroups = ['sudo', 'docker', 'wheel', 'admin'];
203
+ const currentUserGroups = getCurrentUserGroups();
204
+ const inheritedImportantGroups = importantGroups.filter((g) =>
205
+ currentUserGroups.includes(g)
206
+ );
207
+
208
+ if (DEBUG) {
209
+ console.log(`[DEBUG] Current user groups: ${currentUserGroups.join(', ')}`);
210
+ console.log(`[DEBUG] Groups to inherit: ${groups.join(', ')}`);
211
+ console.log(
212
+ `[DEBUG] Important groups found: ${inheritedImportantGroups.join(', ')}`
213
+ );
214
+ }
215
+
216
+ return createUser(username, groups, options);
217
+ }
218
+
219
+ /**
220
+ * Delete a user and optionally their home directory
221
+ * Requires sudo access
222
+ *
223
+ * @param {string} username - Username to delete
224
+ * @param {object} options - Options
225
+ * @param {boolean} options.removeHome - If true, remove home directory
226
+ * @returns {{success: boolean, message: string}}
227
+ */
228
+ function deleteUser(username, options = {}) {
229
+ if (process.platform === 'win32') {
230
+ return {
231
+ success: false,
232
+ message: 'User deletion is not supported on Windows',
233
+ };
234
+ }
235
+
236
+ if (!userExists(username)) {
237
+ return {
238
+ success: true,
239
+ message: `User "${username}" does not exist`,
240
+ };
241
+ }
242
+
243
+ try {
244
+ const userdel = ['sudo', '-n', 'userdel'];
245
+
246
+ if (options.removeHome) {
247
+ userdel.push('-r'); // Remove home directory
248
+ }
249
+
250
+ userdel.push(username);
251
+
252
+ if (DEBUG) {
253
+ console.log(`[DEBUG] Deleting user: ${userdel.join(' ')}`);
254
+ }
255
+
256
+ const result = spawnSync(userdel[0], userdel.slice(1), {
257
+ stdio: ['pipe', 'pipe', 'pipe'],
258
+ encoding: 'utf8',
259
+ });
260
+
261
+ if (result.status !== 0) {
262
+ const stderr = result.stderr || '';
263
+ return {
264
+ success: false,
265
+ message: `Failed to delete user: ${stderr.trim() || 'Unknown error'}`,
266
+ };
267
+ }
268
+
269
+ return {
270
+ success: true,
271
+ message: `Deleted user "${username}"`,
272
+ };
273
+ } catch (err) {
274
+ return {
275
+ success: false,
276
+ message: `Failed to delete user: ${err.message}`,
277
+ };
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Setup sudoers entry for a user to run as another user without password
283
+ * Requires sudo access
284
+ *
285
+ * @param {string} fromUser - User who will run sudo
286
+ * @param {string} toUser - User to run commands as
287
+ * @returns {{success: boolean, message: string}}
288
+ */
289
+ function setupSudoersForUser(fromUser, toUser) {
290
+ if (process.platform === 'win32') {
291
+ return {
292
+ success: false,
293
+ message: 'Sudoers configuration is not supported on Windows',
294
+ };
295
+ }
296
+
297
+ try {
298
+ // Create a sudoers.d entry for this user pair
299
+ const sudoersFile = `/etc/sudoers.d/start-${fromUser}-${toUser}`;
300
+ const sudoersEntry = `${fromUser} ALL=(${toUser}) NOPASSWD: ALL\n`;
301
+
302
+ // Use visudo -c to validate the entry before writing
303
+ const checkResult = spawnSync(
304
+ 'sudo',
305
+ ['-n', 'sh', '-c', `echo '${sudoersEntry}' | visudo -c -f -`],
306
+ {
307
+ stdio: ['pipe', 'pipe', 'pipe'],
308
+ encoding: 'utf8',
309
+ }
310
+ );
311
+
312
+ if (checkResult.status !== 0) {
313
+ return {
314
+ success: false,
315
+ message: `Invalid sudoers entry: ${checkResult.stderr}`,
316
+ };
317
+ }
318
+
319
+ // Write the sudoers file
320
+ const writeResult = spawnSync(
321
+ 'sudo',
322
+ [
323
+ '-n',
324
+ 'sh',
325
+ '-c',
326
+ `echo '${sudoersEntry}' > ${sudoersFile} && chmod 0440 ${sudoersFile}`,
327
+ ],
328
+ {
329
+ stdio: ['pipe', 'pipe', 'pipe'],
330
+ encoding: 'utf8',
331
+ }
332
+ );
333
+
334
+ if (writeResult.status !== 0) {
335
+ return {
336
+ success: false,
337
+ message: `Failed to write sudoers file: ${writeResult.stderr}`,
338
+ };
339
+ }
340
+
341
+ return {
342
+ success: true,
343
+ message: `Created sudoers entry: ${fromUser} can run as ${toUser}`,
344
+ sudoersFile,
345
+ };
346
+ } catch (err) {
347
+ return {
348
+ success: false,
349
+ message: `Failed to setup sudoers: ${err.message}`,
350
+ };
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Get information about a user
356
+ * @param {string} username - Username to query
357
+ * @returns {{exists: boolean, uid?: number, gid?: number, groups?: string[], home?: string, shell?: string}}
358
+ */
359
+ function getUserInfo(username) {
360
+ if (!userExists(username)) {
361
+ return { exists: false };
362
+ }
363
+
364
+ try {
365
+ const idOutput = execSync(`id ${username}`, { encoding: 'utf8' }).trim();
366
+ // Parse: uid=1000(user) gid=1000(group) groups=1000(group),27(sudo)
367
+ const uidMatch = idOutput.match(/uid=(\d+)/);
368
+ const gidMatch = idOutput.match(/gid=(\d+)/);
369
+
370
+ const groupsOutput = execSync(`groups ${username}`, {
371
+ encoding: 'utf8',
372
+ }).trim();
373
+ const groupsPart = groupsOutput.split(':').pop().trim();
374
+ const groups = groupsPart.split(/\s+/).filter(Boolean);
375
+
376
+ // Get home directory and shell from passwd
377
+ let home, shell;
378
+ try {
379
+ const passwdEntry = execSync(`getent passwd ${username}`, {
380
+ encoding: 'utf8',
381
+ }).trim();
382
+ const parts = passwdEntry.split(':');
383
+ if (parts.length >= 7) {
384
+ home = parts[5];
385
+ shell = parts[6];
386
+ }
387
+ } catch {
388
+ // Ignore if getent fails
389
+ }
390
+
391
+ return {
392
+ exists: true,
393
+ uid: uidMatch ? parseInt(uidMatch[1], 10) : undefined,
394
+ gid: gidMatch ? parseInt(gidMatch[1], 10) : undefined,
395
+ groups,
396
+ home,
397
+ shell,
398
+ };
399
+ } catch {
400
+ return { exists: true }; // User exists but couldn't get details
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Check if the current process has sudo access without password
406
+ * @returns {boolean} True if sudo -n works
407
+ */
408
+ function hasSudoAccess() {
409
+ try {
410
+ execSync('sudo -n true', { stdio: ['pipe', 'pipe', 'pipe'] });
411
+ return true;
412
+ } catch {
413
+ return false;
414
+ }
415
+ }
416
+
417
+ module.exports = {
418
+ getCurrentUser,
419
+ getCurrentUserGroups,
420
+ userExists,
421
+ groupExists,
422
+ generateIsolatedUsername,
423
+ createUser,
424
+ createIsolatedUser,
425
+ deleteUser,
426
+ setupSudoersForUser,
427
+ getUserInfo,
428
+ hasSudoAccess,
429
+ };
@@ -513,3 +513,182 @@ describe('VALID_BACKENDS', () => {
513
513
  assert.ok(VALID_BACKENDS.includes('docker'));
514
514
  });
515
515
  });
516
+
517
+ describe('user isolation option', () => {
518
+ it('should parse --isolated-user without value (auto-generated username)', () => {
519
+ const result = parseArgs(['--isolated-user', '--', 'npm', 'test']);
520
+ assert.strictEqual(result.wrapperOptions.user, true);
521
+ assert.strictEqual(result.wrapperOptions.userName, null);
522
+ assert.strictEqual(result.command, 'npm test');
523
+ });
524
+
525
+ it('should parse --isolated-user with custom username', () => {
526
+ const result = parseArgs([
527
+ '--isolated-user',
528
+ 'myrunner',
529
+ '--',
530
+ 'npm',
531
+ 'test',
532
+ ]);
533
+ assert.strictEqual(result.wrapperOptions.user, true);
534
+ assert.strictEqual(result.wrapperOptions.userName, 'myrunner');
535
+ assert.strictEqual(result.command, 'npm test');
536
+ });
537
+
538
+ it('should parse -u shorthand', () => {
539
+ const result = parseArgs(['-u', '--', 'npm', 'start']);
540
+ assert.strictEqual(result.wrapperOptions.user, true);
541
+ assert.strictEqual(result.wrapperOptions.userName, null);
542
+ });
543
+
544
+ it('should parse -u with custom username', () => {
545
+ const result = parseArgs(['-u', 'testuser', '--', 'npm', 'start']);
546
+ assert.strictEqual(result.wrapperOptions.user, true);
547
+ assert.strictEqual(result.wrapperOptions.userName, 'testuser');
548
+ });
549
+
550
+ it('should parse --isolated-user=value format', () => {
551
+ const result = parseArgs([
552
+ '--isolated-user=myrunner',
553
+ '--',
554
+ 'npm',
555
+ 'start',
556
+ ]);
557
+ assert.strictEqual(result.wrapperOptions.user, true);
558
+ assert.strictEqual(result.wrapperOptions.userName, 'myrunner');
559
+ });
560
+
561
+ it('should work with isolation options', () => {
562
+ const result = parseArgs([
563
+ '--isolated',
564
+ 'screen',
565
+ '--isolated-user',
566
+ 'testuser',
567
+ '--',
568
+ 'npm',
569
+ 'start',
570
+ ]);
571
+ assert.strictEqual(result.wrapperOptions.isolated, 'screen');
572
+ assert.strictEqual(result.wrapperOptions.user, true);
573
+ assert.strictEqual(result.wrapperOptions.userName, 'testuser');
574
+ assert.strictEqual(result.command, 'npm start');
575
+ });
576
+
577
+ it('should work without isolation (standalone user isolation)', () => {
578
+ const result = parseArgs(['--isolated-user', '--', 'node', 'server.js']);
579
+ assert.strictEqual(result.wrapperOptions.user, true);
580
+ assert.strictEqual(result.wrapperOptions.isolated, null);
581
+ assert.strictEqual(result.command, 'node server.js');
582
+ });
583
+
584
+ it('should accept valid usernames', () => {
585
+ const validUsernames = [
586
+ 'john',
587
+ 'www-data',
588
+ 'user123',
589
+ 'john-doe',
590
+ 'user_1',
591
+ ];
592
+ for (const username of validUsernames) {
593
+ assert.doesNotThrow(() => {
594
+ parseArgs(['--isolated-user', username, '--', 'echo', 'test']);
595
+ });
596
+ }
597
+ });
598
+
599
+ it('should reject invalid username formats with --isolated-user=value syntax', () => {
600
+ const invalidUsernames = ['john@doe', 'user.name', 'user/name'];
601
+ for (const username of invalidUsernames) {
602
+ assert.throws(() => {
603
+ parseArgs([`--isolated-user=${username}`, '--', 'echo', 'test']);
604
+ }, /Invalid username format/);
605
+ }
606
+ });
607
+
608
+ it('should not consume invalid username as argument (treats as command)', () => {
609
+ // When --isolated-user is followed by an invalid username format, it doesn't consume it
610
+ // The invalid username becomes part of the command instead
611
+ const result = parseArgs([
612
+ '--isolated-user',
613
+ 'john@doe',
614
+ '--',
615
+ 'echo',
616
+ 'test',
617
+ ]);
618
+ assert.strictEqual(result.wrapperOptions.user, true);
619
+ assert.strictEqual(result.wrapperOptions.userName, null);
620
+ // john@doe is not consumed as username, but the -- separator means it's not in command either
621
+ });
622
+
623
+ it('should throw error for user with docker isolation', () => {
624
+ assert.throws(() => {
625
+ parseArgs([
626
+ '--isolated',
627
+ 'docker',
628
+ '--image',
629
+ 'node:20',
630
+ '--isolated-user',
631
+ '--',
632
+ 'npm',
633
+ 'install',
634
+ ]);
635
+ }, /--isolated-user is not supported with Docker isolation/);
636
+ });
637
+
638
+ it('should work with tmux isolation', () => {
639
+ const result = parseArgs([
640
+ '-i',
641
+ 'tmux',
642
+ '--isolated-user',
643
+ 'testuser',
644
+ '--',
645
+ 'npm',
646
+ 'test',
647
+ ]);
648
+ assert.strictEqual(result.wrapperOptions.isolated, 'tmux');
649
+ assert.strictEqual(result.wrapperOptions.user, true);
650
+ assert.strictEqual(result.wrapperOptions.userName, 'testuser');
651
+ });
652
+ });
653
+
654
+ describe('keep-user option', () => {
655
+ it('should parse --keep-user flag', () => {
656
+ const result = parseArgs([
657
+ '--isolated-user',
658
+ '--keep-user',
659
+ '--',
660
+ 'npm',
661
+ 'test',
662
+ ]);
663
+ assert.strictEqual(result.wrapperOptions.user, true);
664
+ assert.strictEqual(result.wrapperOptions.keepUser, true);
665
+ });
666
+
667
+ it('should default keepUser to false', () => {
668
+ const result = parseArgs(['--isolated-user', '--', 'npm', 'test']);
669
+ assert.strictEqual(result.wrapperOptions.keepUser, false);
670
+ });
671
+
672
+ it('should throw error for keep-user without user', () => {
673
+ assert.throws(() => {
674
+ parseArgs(['--keep-user', '--', 'npm', 'test']);
675
+ }, /--keep-user option is only valid with --isolated-user/);
676
+ });
677
+
678
+ it('should work with user and isolation options', () => {
679
+ const result = parseArgs([
680
+ '-i',
681
+ 'screen',
682
+ '--isolated-user',
683
+ 'testuser',
684
+ '--keep-user',
685
+ '--',
686
+ 'npm',
687
+ 'start',
688
+ ]);
689
+ assert.strictEqual(result.wrapperOptions.isolated, 'screen');
690
+ assert.strictEqual(result.wrapperOptions.user, true);
691
+ assert.strictEqual(result.wrapperOptions.userName, 'testuser');
692
+ assert.strictEqual(result.wrapperOptions.keepUser, true);
693
+ });
694
+ });
@@ -16,6 +16,39 @@ const {
16
16
  } = require('../src/lib/isolation');
17
17
 
18
18
  describe('Isolation Module', () => {
19
+ describe('wrapCommandWithUser', () => {
20
+ const { wrapCommandWithUser } = require('../src/lib/isolation');
21
+
22
+ it('should return command unchanged when user is null', () => {
23
+ const command = 'echo hello';
24
+ const result = wrapCommandWithUser(command, null);
25
+ assert.strictEqual(result, command);
26
+ });
27
+
28
+ it('should wrap command with sudo when user is specified', () => {
29
+ const command = 'echo hello';
30
+ const result = wrapCommandWithUser(command, 'john');
31
+ assert.ok(result.includes('sudo'));
32
+ assert.ok(result.includes('-u john'));
33
+ assert.ok(result.includes('echo hello'));
34
+ });
35
+
36
+ it('should escape single quotes in command', () => {
37
+ const command = "echo 'hello'";
38
+ const result = wrapCommandWithUser(command, 'www-data');
39
+ // Should escape quotes properly for shell
40
+ assert.ok(result.includes('sudo'));
41
+ assert.ok(result.includes('-u www-data'));
42
+ });
43
+
44
+ it('should use non-interactive sudo', () => {
45
+ const command = 'npm start';
46
+ const result = wrapCommandWithUser(command, 'john');
47
+ // Should include -n flag for non-interactive
48
+ assert.ok(result.includes('sudo -n'));
49
+ });
50
+ });
51
+
19
52
  describe('isCommandAvailable', () => {
20
53
  it('should return true for common commands (echo)', () => {
21
54
  // echo is available on all platforms