node-command 2.0.3 → 2.0.5

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.
@@ -17,7 +17,7 @@ import os from 'node:os';
17
17
  import path from 'node:path';
18
18
  import zlib from 'node:zlib';
19
19
  import child_process from 'node:child_process';
20
- import { candidateInstallRoots, decideInstallMode, getNcRootForMode, probeInstallRoot } from './setup.mjs';
20
+ import { candidateInstallRoots, decideInstallMode, getNcRootForMode, probeInstallRoot, resolveExecutable } from './setup.mjs';
21
21
 
22
22
  export const CLI_DIAGNOSTICS_VERSION = '1.0.0';
23
23
  export const CLI_DIAGNOSTICS_MAX_LOG_BYTES = 65536;
@@ -121,13 +121,22 @@ export function parseCliDiagnosticsArgs(argv = []) {
121
121
 
122
122
  /**
123
123
  * Best-effort service status for the platform agent units. Never throws.
124
+ * Executables resolve from the same absolute allowlist used by privileged
125
+ * paths, so the caller's PATH cannot redirect a diagnostic probe.
124
126
  * @param {string} [platform]
127
+ * @param {{ run?: (cmd: string, args: string[], options: object) => any, resolve?: typeof resolveExecutable }} [options]
125
128
  */
126
- export function probeCliServiceStatus(platform = os.platform()) {
129
+ export function probeCliServiceStatus(platform = os.platform(), { run = child_process.execFileSync, resolve = resolveExecutable } = {}) {
127
130
  const checks = [];
128
- const tryRun = (cmd, args) => {
131
+ const tryRun = (name, args) => {
132
+ let cmd;
129
133
  try {
130
- const out = child_process.execFileSync(cmd, args, {
134
+ cmd = resolve(name, { platform });
135
+ } catch {
136
+ return { ok: false, stdout: '' };
137
+ }
138
+ try {
139
+ const out = run(cmd, args, {
131
140
  timeout: 8000,
132
141
  stdio: ['ignore', 'pipe', 'pipe']
133
142
  });
@@ -549,17 +558,36 @@ export function runCliDiagnostics(argv = [], { ncRoot = null, env = process.env,
549
558
  writeFile ||
550
559
  ((f, d) => {
551
560
  fs.mkdirSync(path.dirname(f), { recursive: true });
552
- fs.writeFileSync(f, d, { mode: 0o600 });
561
+ // Never follow an existing file or symlink at the predictable output
562
+ // name: O_EXCL makes a pre-created path fail closed instead of
563
+ // truncating its target.
564
+ const fd = fs.openSync(
565
+ f,
566
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0),
567
+ 0o600
568
+ );
569
+ try {
570
+ fs.writeFileSync(fd, d);
571
+ } catch (err) {
572
+ try {
573
+ fs.unlinkSync(f);
574
+ } catch {}
575
+ throw err;
576
+ } finally {
577
+ fs.closeSync(fd);
578
+ }
553
579
  });
554
580
  try {
555
581
  writer(outPath, archive);
556
582
  } catch (err) {
557
583
  const hint =
558
- err?.code === 'EACCES'
559
- ? 'Suggested action: choose a writable --output path or re-run with permission to write there.'
560
- : err?.code === 'ENOSPC'
561
- ? 'Suggested action: free disk space and re-run.'
562
- : 'Suggested action: choose a different --output path and re-run.';
584
+ err?.code === 'EEXIST'
585
+ ? 'Suggested action: the output file already exists and is never overwritten; choose a different --output path and re-run.'
586
+ : err?.code === 'EACCES'
587
+ ? 'Suggested action: choose a writable --output path or re-run with permission to write there.'
588
+ : err?.code === 'ENOSPC'
589
+ ? 'Suggested action: free disk space and re-run.'
590
+ : 'Suggested action: choose a different --output path and re-run.';
563
591
  return {
564
592
  handled: true,
565
593
  ok: false,
package/lib/setup.mjs CHANGED
@@ -45,17 +45,69 @@ export function browserBrandReexecArgs(args, brand) {
45
45
  return ['--input-type=module', '--eval', script];
46
46
  }
47
47
 
48
+ /**
49
+ * Canonical absolute Windows executable path derived only from the trusted
50
+ * SystemRoot (never from the caller's PATH).
51
+ * @param {'powershell'|'tar'|'sc'|'sc.exe'|'net'|'net.exe'} name
52
+ * @param {NodeJS.ProcessEnv} [env]
53
+ */
54
+ export function windowsExecutablePath(name, env = process.env) {
55
+ const systemRoot = env.SystemRoot || env.windir || 'C:\\Windows';
56
+ if (name === 'powershell') return path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
57
+ if (name === 'tar') return path.join(systemRoot, 'System32', 'tar.exe');
58
+ if (name === 'sc' || name === 'sc.exe') return path.join(systemRoot, 'System32', 'sc.exe');
59
+ if (name === 'net' || name === 'net.exe') return path.join(systemRoot, 'System32', 'net.exe');
60
+ throw new Error(`No canonical Windows executable mapping for ${name}.`);
61
+ }
62
+
63
+ /**
64
+ * Resolves a required helper executable only from an absolute allowlist, so a
65
+ * caller-controlled PATH cannot redirect an elevation or install flow.
66
+ * @param {'bash'|'tar'|'sudo'|'id'|'powershell'|'systemctl'|'launchctl'|'sc'|'sc.exe'|'net'|'net.exe'} name
67
+ * @param {{ platform?: string, env?: NodeJS.ProcessEnv, existsSync?: (p: string) => boolean }} [options]
68
+ */
69
+ export function resolveExecutable(name, { platform = os.platform(), env = process.env, existsSync = fs.existsSync } = {}) {
70
+ const unavailable = (expected) => Object.assign(
71
+ new Error(`Required executable ${name} was not found at ${expected}; refusing to resolve it through PATH.`),
72
+ { code: 'CLI_EXECUTABLE_UNAVAILABLE' }
73
+ );
74
+ if (platform === 'win32') {
75
+ const candidate = windowsExecutablePath(name, env);
76
+ if (existsSync(candidate)) return candidate;
77
+ throw unavailable(candidate);
78
+ }
79
+ /** @type {Record<string, string[]>} */
80
+ const allowlist = {
81
+ bash: ['/bin/bash', '/usr/bin/bash'],
82
+ tar: ['/bin/tar', '/usr/bin/tar'],
83
+ sudo: ['/usr/bin/sudo', '/bin/sudo'],
84
+ id: ['/usr/bin/id', '/bin/id'],
85
+ systemctl: ['/usr/bin/systemctl', '/bin/systemctl', '/usr/sbin/systemctl', '/sbin/systemctl'],
86
+ launchctl: ['/bin/launchctl', '/usr/bin/launchctl']
87
+ };
88
+ const candidates = allowlist[name];
89
+ if (!candidates) throw new Error(`No POSIX executable allowlist for ${name}.`);
90
+ for (const candidate of candidates) {
91
+ if (existsSync(candidate)) return candidate;
92
+ }
93
+ throw unavailable(candidates.join(' or '));
94
+ }
95
+
48
96
  /** Single-line notice printed when setup falls back to user mode. */
49
97
  export const LIMITED_CAPS_WARNING =
50
98
  'limited: privileged_shell_exec and container tools are off (re-run as administrator for full capabilities)';
51
99
 
52
100
  /**
53
101
  * Detects Windows administrator rights via `net session` (exit 0 ⇔ admin).
54
- * Never throws: any failure means "not admin".
102
+ * The production path resolves the absolute `net.exe`; an injected runner
103
+ * keeps the stable `net` command name for existing fixtures. Never throws:
104
+ * any failure means "not admin".
55
105
  * @param {(cmd: string, args: string[]) => { status: number|null }} [run]
56
106
  */
57
107
  export function detectWindowsAdmin(run = null) {
58
108
  try {
109
+ if (!run && os.platform() !== 'win32') return false;
110
+ const command = run ? 'net' : windowsExecutablePath('net');
59
111
  const runner =
60
112
  run ||
61
113
  ((cmd, args) => {
@@ -66,7 +118,7 @@ export function detectWindowsAdmin(run = null) {
66
118
  return { status: 1 };
67
119
  }
68
120
  });
69
- return runner('net', ['session']).status === 0;
121
+ return runner(command, ['session']).status === 0;
70
122
  } catch {
71
123
  return false;
72
124
  }
@@ -108,6 +160,16 @@ export function decideInstallMode(options = {}) {
108
160
  return isElevated(options) ? 'system' : 'user';
109
161
  }
110
162
 
163
+ /**
164
+ * A partial per-user application (valid config + device key, missing or empty
165
+ * app/launcher.mjs) may be repaired in place from the stored host identity.
166
+ * Protected or root-managed system trees always require explicit maintenance.
167
+ * @param {{ mode?: 'system'|'user', protectedContext?: unknown, rootManagedPair?: boolean }} [options]
168
+ */
169
+ export function canRepairUserApplication({ mode, protectedContext = null, rootManagedPair = false } = {}) {
170
+ return mode === 'user' && !protectedContext && rootManagedPair !== true;
171
+ }
172
+
111
173
  /**
112
174
  * True when the current user CAN elevate to a machine-wide install even when
113
175
  * the process is not root yet: an already-elevated process, a Windows
@@ -124,7 +186,7 @@ export function canElevate(options = {}) {
124
186
  // UAC prompt, so elevation is available even when not administrator.
125
187
  if (platform === 'win32') return true;
126
188
  try {
127
- const r = child_process.spawnSync('id', ['-nG'], { encoding: 'utf8' });
189
+ const r = child_process.spawnSync(resolveExecutable('id', { platform }), ['-nG'], { encoding: 'utf8' });
128
190
  const groups = r && typeof r.stdout === 'string' ? r.stdout : '';
129
191
  return /\b(sudo|admin|wheel)\b/.test(groups);
130
192
  } catch {
@@ -329,6 +391,60 @@ export function probeInstallRoot(root, io = {}) {
329
391
  return 'foreign';
330
392
  }
331
393
 
394
+ /**
395
+ * Removal-strict install-tree probe. Removing a tree is destructive, so a
396
+ * readable `config/nc-client.env` marker is required; an `app/` directory
397
+ * alone (an incomplete or foreign tree) is never a removal target.
398
+ * Distinguishes:
399
+ * - 'install' config marker is readable
400
+ * - 'absent' root directory does not exist
401
+ * - 'foreign' root exists but is not a directory, or the config marker is
402
+ * missing / not a regular file
403
+ * - 'unreadable' root or config marker denies access (EACCES/EPERM)
404
+ * @param {string} root
405
+ * @param {{ statSync?: (p: string) => { isDirectory(): boolean, isFile?(): boolean }, accessSync?: (p: string, mode?: number) => void }} [io]
406
+ * @returns {'install'|'absent'|'foreign'|'unreadable'}
407
+ */
408
+ export function probeRemovalRoot(root, io = {}) {
409
+ const statSync = io.statSync || fs.statSync;
410
+ const accessSync = io.accessSync || fs.accessSync;
411
+ try {
412
+ const st = statSync(root);
413
+ if (!st.isDirectory()) return 'foreign';
414
+ } catch (err) {
415
+ return err && err.code === 'ENOENT' ? 'absent' : 'unreadable';
416
+ }
417
+ const marker = path.join(root, 'config', 'nc-client.env');
418
+ try {
419
+ const st = statSync(marker);
420
+ if (typeof st.isFile === 'function' && !st.isFile()) return 'foreign';
421
+ } catch (err) {
422
+ return err && (err.code === 'EACCES' || err.code === 'EPERM') ? 'unreadable' : 'foreign';
423
+ }
424
+ try {
425
+ accessSync(marker, fs.constants.R_OK);
426
+ return 'install';
427
+ } catch (err) {
428
+ return err && (err.code === 'EACCES' || err.code === 'EPERM') ? 'unreadable' : 'foreign';
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Canonical machine-wide roots that exist but cannot be inspected by the
434
+ * current unprivileged caller. Setup must escalate before deciding anything
435
+ * about them: treating an unreadable system tree as absent would create a
436
+ * competing per-user agent beside a real system installation.
437
+ * @param {{ platform?: string, env?: NodeJS.ProcessEnv, homedir?: string, io?: object }} [options]
438
+ * @returns {string[]}
439
+ */
440
+ export function unreadableSystemInstallations({ platform = os.platform(), env = process.env, homedir = os.homedir(), io = {} } = {}) {
441
+ if (platform !== 'linux') return [];
442
+ const envWithoutRoot = { ...env };
443
+ delete envWithoutRoot.NC_ROOT;
444
+ const root = getNcRootForMode('system', { platform, env: envWithoutRoot, homedir });
445
+ return probeInstallRoot(root, io) === 'unreadable' ? [root] : [];
446
+ }
447
+
332
448
  /**
333
449
  * Removal targets split by accessibility. `install` roots are removed by the
334
450
  * local flow; `unreadable` roots (system installs seen from a non-root shell)
@@ -346,7 +462,7 @@ export function findRemovalTargets({ io = {}, ...rest } = {}) {
346
462
  const roots = candidateInstallRoots(pair ? { ...rest, env: { ...env, NC_ROOT: undefined } } : rest);
347
463
  for (const root of pair ? [...new Set([...roots, pair.ncRoot])] : roots) {
348
464
  if (root === pair?.helperRoot && root !== pair.ncRoot) continue;
349
- const status = probeInstallRoot(root, io);
465
+ const status = probeRemovalRoot(root, io);
350
466
  if (status === 'install') install.push(root);
351
467
  else if (status === 'unreadable') unreadable.push(root);
352
468
  }
@@ -500,7 +616,7 @@ export function setupTarget({ platform = os.platform(), mode = decideInstallMode
500
616
  const script = "$ErrorActionPreference='Stop'; $s=@(Get-CimInstance Win32_Service -Filter \"Name='NCClient'\" | Select-Object PathName); $t=@(Get-ScheduledTask -TaskPath '\\' | Where-Object { $_.TaskName -eq 'NC Client' } | ForEach-Object { $_.Actions | Select-Object WorkingDirectory }); ConvertTo-Json -Compress -Depth 4 @{services=$s;tasks=$t}";
501
617
  let state;
502
618
  try {
503
- const result = run('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], { encoding: 'utf8', timeout: 10000, maxBuffer: 65536, windowsHide: true });
619
+ const result = run(windowsExecutablePath('powershell'), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], { encoding: 'utf8', timeout: 10000, maxBuffer: 65536, windowsHide: true });
504
620
  if (result.status !== 0) stop('Windows service/task state is unreadable; inspect NCClient and the NC Client task through administrator maintenance.');
505
621
  state = JSON.parse(String(result.stdout));
506
622
  if (!Array.isArray(state.services) || !Array.isArray(state.tasks)) throw new Error('invalid service state');
@@ -624,7 +740,16 @@ export async function maintainLinuxIdentity({ ncRoot, hostId, operatorUser, auth
624
740
  } finally { if (fd !== undefined) io.closeSync(fd); io.closeSync(parent); }
625
741
  };
626
742
  const envPath = path.join(ncRoot, 'config/nc-client.env');
627
- const envText = read(envPath), env = parseMaintenanceEnv(envText);
743
+ let envText;
744
+ try { envText = read(envPath); } catch (error) {
745
+ if (error?.code !== 'ENOENT') throw error;
746
+ // A completed login-user migration retires the source configuration; a
747
+ // second run must not surface a raw filesystem error or repeat OS work.
748
+ if (exists(envPath + '.identity-backup'))
749
+ fail('this tree was already migrated to the restricted nodecommand identity; no account or service was changed');
750
+ fail('the selected root has no readable installation configuration; preserve it and repair explicitly');
751
+ }
752
+ const env = parseMaintenanceEnv(envText);
628
753
  if (env.NC_CLIENT_ID !== hostId) fail('host ID mismatch; no token was consumed');
629
754
  const identity = root => {
630
755
  const data = parseMaintenanceEnv(read(path.join(root, 'config/nc-client.env')));
@@ -643,6 +768,10 @@ export async function maintainLinuxIdentity({ ncRoot, hostId, operatorUser, auth
643
768
  if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) fail('source root is not a directory');
644
769
  const loginMove = env.NC_SERVICE_MODE === 'user';
645
770
  const oldUser = loginMove ? operatorUser : '_nc';
771
+ // Re-selecting the already-migrated restricted installation is a no-op that
772
+ // must fail closed with its own reason instead of an account message.
773
+ if (loginMove && operatorUser === 'nodecommand')
774
+ fail('this installation already runs as the restricted nodecommand identity; no account or service was changed');
646
775
  if (!/^[a-z_][a-z0-9_-]*[$]?$/.test(oldUser || '') || oldUser === 'root' || oldUser === 'nodecommand') fail('select the existing ordinary account explicitly');
647
776
  const account = command('/usr/bin/getent', ['passwd', oldUser]).trim().split(':');
648
777
  if (account.length !== 7 || account[0] !== oldUser || !/^\d+$/.test(account[2]) || !/^\d+$/.test(account[3])) fail('account inventory is ambiguous');
@@ -798,6 +927,12 @@ export async function maintainLinuxIdentity({ ncRoot, hostId, operatorUser, auth
798
927
  const created = command('/usr/bin/getent', ['passwd', 'nodecommand']).trim().split(':');
799
928
  newUid = Number(created[2]); newGid = Number(created[3]);
800
929
  if (!Number.isSafeInteger(newUid) || newUid <= 0 || !Number.isSafeInteger(newGid) || newGid <= 0 || created[5] !== home) fail('new account inventory mismatch');
930
+ // The created identity must itself be restricted: non-login shell and no
931
+ // supplementary groups (never a reused login or elevated account).
932
+ const createdGroups = command('/usr/bin/id', ['-nG', 'nodecommand']).trim().split(/\s+/).filter(Boolean);
933
+ if (createdGroups.length !== 1 || createdGroups[0] !== 'nodecommand' ||
934
+ !['/usr/sbin/nologin', '/sbin/nologin', '/bin/false', '/usr/bin/false'].includes(created[6]))
935
+ fail('new restricted account is not a dedicated non-login identity; repair explicitly');
801
936
  const rewritten = { ...parseMaintenanceEnv(read(path.join(destination, 'config/nc-client.env'))), NC_SERVICE_MODE: 'user',
802
937
  NC_ROOT: destination, NC_SERVICE_UNIT: 'nc-client.service', NC_OPERATOR_USER: 'nodecommand', NC_SERVICE_USER: 'nodecommand', NC_SERVICE_GROUP: 'nodecommand' };
803
938
  for (const name of ['NC_ENV_FILE', 'NC_LOG_FILE', 'NC_DEVICE_KEY_FILE', 'NC_NODE_BIN']) {
@@ -945,7 +1080,7 @@ export const LINUX_PAIRED_UNINSTALL_ENTRY = '/opt/nc/libexec/privileged-helper/i
945
1080
  * Never forward startup hooks or loader/environment authority to that shell.
946
1081
  * @param {string} scriptPath
947
1082
  * @param {string|{ platform?: string, execFile?: (cmd: string, args: string[], opts?: object) => unknown, getuid?: (() => number)|null }} [platform]
948
- * @param {{ execFile?: (cmd: string, args: string[], opts?: object) => unknown, getuid?: (() => number)|null, geteuid?: (() => number)|null, io?: typeof fs }} [options]
1083
+ * @param {{ execFile?: (cmd: string, args: string[], opts?: object) => unknown, getuid?: (() => number)|null, geteuid?: (() => number)|null, io?: typeof fs, env?: NodeJS.ProcessEnv, homedir?: string }} [options]
949
1084
  */
950
1085
  export function runUninstallerScript(scriptPath, platform = os.platform(), options = {}) {
951
1086
  let opts = options || {};
@@ -955,8 +1090,40 @@ export function runUninstallerScript(scriptPath, platform = os.platform(), optio
955
1090
  }
956
1091
  const execFile = opts.execFile || child_process.execFileSync;
957
1092
  if (platform === 'win32') {
1093
+ // NC-SRC-02: only the packaged uninstaller of a known installation root (or
1094
+ // the Administrators/SYSTEM-only copy the installer registers for the SYSTEM
1095
+ // task) may execute — never an arbitrary path from the caller.
1096
+ const winpath = path.win32;
1097
+ const denied = () => Object.assign(new Error('TRUSTED_ROOT_MAINTENANCE_REQUIRED: refusing untrusted uninstall execution; preserve the selected tree and use explicitly trusted maintenance.'), { code: 'TRUSTED_ROOT_MAINTENANCE_REQUIRED' });
1098
+ const env = opts.env || process.env;
1099
+ const homedir = opts.homedir || os.homedir();
1100
+ const programData = env.ProgramData || 'C:\\ProgramData';
1101
+ const expected = new Set([
1102
+ ...candidateInstallRoots({ platform: 'win32', env, homedir }).map(root => winpath.join(root, 'uninstall.ps1')),
1103
+ winpath.join(programData, 'NC Protected', 'uninstall.ps1')
1104
+ ].map(file => file.toLowerCase()));
1105
+ if (typeof scriptPath !== 'string' || !winpath.isAbsolute(scriptPath) ||
1106
+ winpath.normalize(scriptPath) !== scriptPath || /["`\x00-\x1F\x7F]/.test(scriptPath) ||
1107
+ winpath.basename(scriptPath).toLowerCase() !== 'uninstall.ps1' || !expected.has(scriptPath.toLowerCase())) throw denied();
1108
+ // Discovery is not execution trust: the packaged file itself must be the
1109
+ // regular (non-reparse) file at that path, with no redirected ancestry.
1110
+ const io = opts.io || fs;
1111
+ let st;
1112
+ try {
1113
+ st = io.lstatSync(scriptPath);
1114
+ } catch {
1115
+ throw denied();
1116
+ }
1117
+ if (st.isSymbolicLink() || !st.isFile()) throw denied();
1118
+ let real;
1119
+ try {
1120
+ real = io.realpathSync(scriptPath);
1121
+ } catch {
1122
+ throw denied();
1123
+ }
1124
+ if (winpath.normalize(real).toLowerCase() !== scriptPath.toLowerCase()) throw denied();
958
1125
  execFile(
959
- 'powershell.exe',
1126
+ windowsExecutablePath('powershell', env),
960
1127
  ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath],
961
1128
  { stdio: 'inherit' }
962
1129
  );
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "node-command",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
+ "engines": {
7
+ "node": ">=20"
8
+ },
6
9
  "bin": {
7
10
  "nc": "bin/nc.mjs",
8
11
  "nodecommand": "bin/nc.mjs",
@@ -12,22 +15,5 @@
12
15
  "files": [
13
16
  "bin/",
14
17
  "lib/"
15
- ],
16
- "description": "Node Command agent CLI — connect this machine to your AI assistant fleet",
17
- "repository": {
18
- "url": "git+https://github.com/huseyincig/NodeCommand.git",
19
- "type": "git"
20
- },
21
- "engines": {
22
- "node": ">=20"
23
- },
24
- "keywords": [
25
- "node-command",
26
- "nodecommand",
27
- "mcp",
28
- "remote",
29
- "ai-assistant",
30
- "agent",
31
- "cli"
32
18
  ]
33
19
  }