@goodandready/dsh-remote-workspace 0.3.14 → 0.3.15

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/routes/env.js CHANGED
@@ -1,6 +1,6 @@
1
- import { writeJson, readBody, authorizeRequest } from './helpers.js';
1
+ import { writeJson, readBody, authorizeRequest, validateRemotePath } from './helpers.js';
2
2
 
3
- export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService, sshService, store, remoteFs }) {
3
+ export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService, store, remoteFs }) {
4
4
  // 10. POST /dsh-remote-workspace/env/view
5
5
  sctx.effect(() => webServer.register({
6
6
  kind: 'exact',
@@ -12,7 +12,8 @@ export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService
12
12
  const { profileId, remotePath } = await readBody(req);
13
13
  const profile = store.getProfile(profileId);
14
14
  if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
15
- const targetPath = remotePath || (profile.remoteWorkspace ? `${profile.remoteWorkspace.replace(/\/+$/, '')}/.env` : '.env');
15
+ const defaultPath = profile.remoteWorkspace ? `${profile.remoteWorkspace.replace(/\/+$/, '')}/.env` : '.env';
16
+ const targetPath = validateRemotePath(remotePath || defaultPath);
16
17
  const result = await envService.readEnv(profile, targetPath);
17
18
  writeJson(res, 200, { ok: true, ...result });
18
19
  } catch (err) {
@@ -32,11 +33,19 @@ export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService
32
33
  const { profileId, remotePath, content, key, value } = await readBody(req);
33
34
  const profile = store.getProfile(profileId);
34
35
  if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
35
- const targetPath = remotePath || (profile.remoteWorkspace ? `${profile.remoteWorkspace.replace(/\/+$/, '')}/.env` : '.env');
36
+ const defaultPath = profile.remoteWorkspace ? `${profile.remoteWorkspace.replace(/\/+$/, '')}/.env` : '.env';
37
+ const targetPath = validateRemotePath(remotePath || defaultPath);
38
+ const baseName = targetPath.split('/').pop();
39
+ if (!baseName.includes('env')) {
40
+ return writeJson(res, 400, { ok: false, error: 'Target path must be an environment file' });
41
+ }
36
42
  let result;
37
43
  if (key) {
38
44
  result = await envService.setEnvVar(profile, targetPath, key, value || '');
39
45
  } else if (content !== undefined) {
46
+ if (typeof content !== 'string' && !Buffer.isBuffer(content)) {
47
+ return writeJson(res, 400, { ok: false, error: 'Content must be a string or buffer' });
48
+ }
40
49
  if (remoteFs && typeof remoteFs.writeFile === 'function') {
41
50
  await remoteFs.writeFile(profile, targetPath, content);
42
51
  }
@@ -1,19 +1,15 @@
1
- import { writeJson, readBody, isTrustedSettingsRequest, authorizeRequest } from './helpers.js';
1
+ import { writeJson, readBody, authorizeRequest, validateRemotePath } from './helpers.js';
2
2
  import { contentDisposition, pipeWithProgress, MAX_BYTES } from '../file-transfer.js';
3
3
 
4
4
 
5
5
  function transferTarget(req, store) {
6
6
  const url = new URL(req.url || '/', 'http://localhost');
7
7
  const profileId = url.searchParams.get('profileId') || '';
8
- const filePath = url.searchParams.get('filePath') || '';
9
- if (!filePath || filePath.length > 1024 || filePath.includes('\0')) {
10
- const err = new Error('A file path is required');
11
- err.status = 400;
12
- throw err;
13
- }
8
+ const filePath = validateRemotePath(url.searchParams.get('filePath'));
14
9
  const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
15
10
  if (!profile) {
16
11
  const err = new Error('Profile not found');
12
+ err.statusCode = 404;
17
13
  err.status = 404;
18
14
  throw err;
19
15
  }
@@ -44,7 +40,8 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
44
40
  }
45
41
  if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
46
42
 
47
- const targetPath = body.remotePath || body.path || profile.remoteWorkspace || profile.remotePath || '.';
43
+ const rawPath = body.remotePath || body.path || profile.remoteWorkspace || profile.remotePath || '.';
44
+ const targetPath = validateRemotePath(rawPath);
48
45
  const entries = await remoteFs.listDir(profile, targetPath);
49
46
  writeJson(res, 200, { ok: true, entries, items: entries, currentPath: targetPath });
50
47
  } catch (err) {
@@ -62,13 +59,14 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
62
59
  if (!authorizeRequest(sctx, req, res)) return;
63
60
  try {
64
61
  const { profileId, filePath } = await readBody(req);
62
+ const targetPath = validateRemotePath(filePath);
65
63
  const profile = store.getProfile(profileId);
66
64
  if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
67
- const stat = await remoteFs.stat(profile, filePath);
65
+ const stat = await remoteFs.stat(profile, targetPath);
68
66
  if (stat.size > 2 * 1024 * 1024) {
69
67
  return writeJson(res, 400, { ok: false, error: 'File exceeds 2MB preview limit' });
70
68
  }
71
- const buf = await remoteFs.readFile(profile, filePath);
69
+ const buf = await remoteFs.readFile(profile, targetPath);
72
70
  writeJson(res, 200, { ok: true, content: buf.toString('utf8'), size: stat.size });
73
71
  } catch (err) {
74
72
  writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
@@ -85,9 +83,13 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
85
83
  if (!authorizeRequest(sctx, req, res)) return;
86
84
  try {
87
85
  const { profileId, filePath, content } = await readBody(req);
86
+ const targetPath = validateRemotePath(filePath);
87
+ if (content === undefined) {
88
+ return writeJson(res, 400, { ok: false, error: 'Content is required' });
89
+ }
88
90
  const profile = store.getProfile(profileId);
89
91
  if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
90
- await remoteFs.writeFile(profile, filePath, Buffer.from(content, 'utf8'));
92
+ await remoteFs.writeFile(profile, targetPath, Buffer.from(content, 'utf8'));
91
93
  writeJson(res, 200, { ok: true });
92
94
  } catch (err) {
93
95
  writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
@@ -80,51 +80,76 @@ export function isLoopbackAddress(address) {
80
80
  return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1' || address.startsWith('127.');
81
81
  }
82
82
 
83
- export function isTrustedSettingsRequest(req) {
84
- const ip = req?.socket?.remoteAddress || '';
85
- const isLoopback = isLoopbackAddress(ip);
83
+ export function isTrustedSettingsRequest(req, expectedToken = null) {
84
+ if (!req || !req.headers || typeof req.headers !== 'object') return false;
85
+ const headers = req.headers;
86
86
 
87
- const secFetch = req?.headers?.['sec-fetch-site'];
88
- // Fail-closed: unconditionally reject cross-site and cross-origin requests
87
+ const secFetch = headers['sec-fetch-site'];
88
+ // Fail-closed: unconditionally reject cross-site and non-same-origin Sec-Fetch-Site
89
89
  if (secFetch && secFetch !== 'same-origin') {
90
90
  return false;
91
91
  }
92
92
 
93
- // If origin is present, ensure it matches the request host
94
- const origin = req?.headers?.['origin'];
95
- const host = req?.headers?.['host'];
96
- if (origin && host) {
93
+ // Reject invalid or mismatched origin when provided (CSRF protection)
94
+ const origin = headers['origin'];
95
+ const host = headers['host'];
96
+ if (origin !== undefined) {
97
+ if (origin === 'null' || !origin) return false;
97
98
  try {
98
- const url = new URL(origin);
99
- if (url.host !== host) {
100
- return false;
101
- }
99
+ const originUrl = new URL(origin);
100
+ if (host && originUrl.host !== host) return false;
102
101
  } catch {
103
102
  return false;
104
103
  }
105
104
  }
106
105
 
107
- const rawAuth = req?.headers?.['authorization'];
108
- const cookieHeader = req?.headers?.['cookie'];
109
- const expectedToken = process.env.DSH_AUTH_TOKEN || process.env.DSH_TOKEN;
106
+ const tokenCandidate = (typeof expectedToken === 'string' && expectedToken)
107
+ || process.env.DSH_AUTH_TOKEN
108
+ || process.env.DSH_TOKEN
109
+ || null;
110
110
 
111
- if (expectedToken) {
112
- if (typeof rawAuth === 'string' && rawAuth.startsWith('Bearer ')) {
113
- const token = rawAuth.slice(7).trim();
114
- if (token && token === expectedToken) return true;
111
+ const rawAuth = headers['authorization'];
112
+ if (typeof rawAuth === 'string' && rawAuth.startsWith('Bearer ')) {
113
+ const bearer = rawAuth.slice(7).trim();
114
+ if (tokenCandidate && bearer && bearer === tokenCandidate) {
115
+ return true;
115
116
  }
116
- if (typeof cookieHeader === 'string') {
117
- const cookies = parseCookies(cookieHeader);
118
- const token = cookies['token'] || cookies['dsh_token'];
119
- if (token && token === expectedToken) return true;
117
+ }
118
+
119
+ const cookieHeader = headers['cookie'];
120
+ if (typeof cookieHeader === 'string' && tokenCandidate) {
121
+ const cookies = parseCookies(cookieHeader);
122
+ const token = cookies['token'] || cookies['dsh_token'];
123
+ if (token && token === tokenCandidate) {
124
+ return true;
120
125
  }
126
+ }
127
+
128
+ // If a token is configured but neither header nor cookie matched, reject
129
+ if (tokenCandidate) {
121
130
  return false;
122
131
  }
123
132
 
124
- // Non-loopback requests without valid token must not pass simply on Sec-Fetch-Site: same-origin
125
- if (!isLoopback) return false;
126
- if (secFetch === 'same-origin') return true;
127
- return true;
133
+ const ip = req.socket?.remoteAddress || req.connection?.remoteAddress || '';
134
+ const hasRemote = Boolean(ip && typeof ip === 'string');
135
+ const isLoopback = hasRemote && isLoopbackAddress(ip);
136
+
137
+ // Non-loopback network callers require explicit token authentication
138
+ if (hasRemote && !isLoopback) {
139
+ return false;
140
+ }
141
+
142
+ // Browser same-origin navigation / fetch
143
+ if (secFetch === 'same-origin') {
144
+ return true;
145
+ }
146
+
147
+ // Local loopback caller (direct CLI / loopback curl)
148
+ if (isLoopback) {
149
+ return true;
150
+ }
151
+
152
+ return false;
128
153
  }
129
154
 
130
155
  export function getConnection(ctx) {
@@ -136,6 +161,12 @@ export function getConnection(ctx) {
136
161
  }
137
162
 
138
163
  export function authorizeRequest(sctx, req, res) {
164
+ // First layer: Cross-origin / CSRF / remote network trust check
165
+ if (!isTrustedSettingsRequest(req)) {
166
+ writeJson(res, 403, { ok: false, error: 'Forbidden' });
167
+ return false;
168
+ }
169
+
139
170
  let connection;
140
171
  try {
141
172
  connection = getConnection(sctx);
@@ -168,3 +199,48 @@ export function authorizeRequest(sctx, req, res) {
168
199
 
169
200
  return true;
170
201
  }
202
+
203
+
204
+ export function validateRemotePath(filePath, options = {}) {
205
+ const { allowEmpty = false, maxLength = 1024 } = options;
206
+ if (!filePath && filePath !== '') {
207
+ if (allowEmpty) return '';
208
+ const err = new Error('A file path is required');
209
+ err.statusCode = 400;
210
+ err.status = 400;
211
+ throw err;
212
+ }
213
+ if (typeof filePath !== 'string') {
214
+ const err = new Error('File path must be a string');
215
+ err.statusCode = 400;
216
+ err.status = 400;
217
+ throw err;
218
+ }
219
+ const trimmed = filePath.trim();
220
+ if (!trimmed) {
221
+ if (allowEmpty) return '';
222
+ const err = new Error('A file path is required');
223
+ err.statusCode = 400;
224
+ err.status = 400;
225
+ throw err;
226
+ }
227
+ if (trimmed.length > maxLength) {
228
+ const err = new Error(`File path exceeds maximum length of ${maxLength} characters`);
229
+ err.statusCode = 400;
230
+ err.status = 400;
231
+ throw err;
232
+ }
233
+ if (trimmed.includes('\0')) {
234
+ const err = new Error('File path cannot contain null bytes');
235
+ err.statusCode = 400;
236
+ err.status = 400;
237
+ throw err;
238
+ }
239
+ if (/[\x00-\x1f]/.test(trimmed)) {
240
+ const err = new Error('File path cannot contain control characters');
241
+ err.statusCode = 400;
242
+ err.status = 400;
243
+ throw err;
244
+ }
245
+ return trimmed;
246
+ }
@@ -1,4 +1,8 @@
1
- import { writeJson, readBody, isTrustedSettingsRequest, authorizeRequest } from './helpers.js';
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { isWithinDir } from '../ssh-config-parser.js';
4
+ import { validateProxyCommandTemplate } from '../proxy-command.js';
5
+ import { writeJson, readBody, authorizeRequest } from './helpers.js';
2
6
  import { SshConfigParser } from '../ssh-config-parser.js';
3
7
  import { testProfileGroup } from '../profile-groups.js';
4
8
  import { filterClusterProfiles, runCluster } from '../cluster-service.js';
@@ -48,6 +52,9 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
48
52
  if (!profile.id || !profile.host) {
49
53
  return writeJson(res, 400, { ok: false, error: 'ID and Host are required' });
50
54
  }
55
+ if (profile.proxyCommand) {
56
+ validateProxyCommandTemplate(profile.proxyCommand);
57
+ }
51
58
  sshService.invalidate(profile.id);
52
59
  await store.saveProfile(profile);
53
60
  const sanitized = store.vault ? store.vault.sanitizeProfile(profile) : maskProfile(profile);
@@ -108,7 +115,12 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
108
115
  ? body.content
109
116
  : SshConfigParser.readDefaultConfig();
110
117
  if (!content) return writeJson(res, 400, { ok: false, error: 'SSH config content is required' });
111
- const report = SshConfigParser.parseReport(content, { baseDir: body.baseDir });
118
+ const sshDir = path.join(os.homedir(), '.ssh');
119
+ const allowedRoot = sshDir;
120
+ const baseDir = (body.baseDir && isWithinDir(body.baseDir, allowedRoot))
121
+ ? path.resolve(body.baseDir)
122
+ : sshDir;
123
+ const report = SshConfigParser.parseReport(content, { baseDir, allowedRoot });
112
124
  for (const p of report.profiles) {
113
125
  await store.saveProfile(p);
114
126
  }
@@ -218,7 +230,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
218
230
  const remoteOs = result.os !== undefined ? result.os : (result.remoteOs ?? 'Linux/Unix');
219
231
  writeJson(res, 200, { ok: true, latency: latencyMs, latencyMs, os: remoteOs, remoteOs });
220
232
  } catch (err) {
221
- writeJson(res, 200, { ok: false, error: err.message });
233
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 502), { ok: false, error: err.message });
222
234
  }
223
235
  }
224
236
  }), 'dsh-remote-workspace: /test');
@@ -1,4 +1,4 @@
1
- import { writeJson, readBody, isTrustedSettingsRequest, authorizeRequest } from './helpers.js';
1
+ import { writeJson, readBody, authorizeRequest } from './helpers.js';
2
2
 
3
3
  export function registerSyncRoutes(sctx, webServer, { mirrorSync, tarSyncService, watcherService, store }) {
4
4
  // 17. POST /dsh-remote-workspace/sync
@@ -1,4 +1,4 @@
1
- import { writeJson, readBody, isTrustedSettingsRequest, authorizeRequest } from './helpers.js';
1
+ import { writeJson, readBody, authorizeRequest } from './helpers.js';
2
2
  import { normalizeTerminalFont } from '../terminal-font.js';
3
3
 
4
4
  export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
@@ -1,4 +1,4 @@
1
- import { writeJson, readBody, isTrustedSettingsRequest, authorizeRequest } from './helpers.js';
1
+ import { writeJson, readBody, authorizeRequest } from './helpers.js';
2
2
 
3
3
  export function registerTunnelRoutes(sctx, webServer, { tunnelService, store }) {
4
4
  // 18. POST /dsh-remote-workspace/tunnels/start
@@ -1,3 +1,4 @@
1
+ import { validateProxyCommandTemplate } from './proxy-command.js';
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import os from 'node:os';
@@ -7,19 +8,48 @@ function expandHome(value) {
7
8
  return value;
8
9
  }
9
10
 
10
- function defaultExpandInclude(pattern, baseDir) {
11
- const expanded = expandHome(pattern);
12
- const full = path.isAbsolute(expanded) ? expanded : path.join(baseDir, expanded);
11
+
12
+ export function isWithinDir(targetPath, parentDir) {
13
+ if (!targetPath || !parentDir) return false;
14
+ const normTarget = path.resolve(targetPath);
15
+ const normParent = path.resolve(parentDir);
16
+ const prefix = normParent.endsWith(path.sep) ? normParent : normParent + path.sep;
17
+ return normTarget === normParent || normTarget.startsWith(prefix);
18
+ }
19
+
20
+ function defaultExpandInclude(pattern, baseDir, allowedRoot = null) {
21
+ if (typeof pattern !== 'string' || !pattern.trim() || pattern.includes('\0')) {
22
+ return [];
23
+ }
24
+ const expanded = expandHome(pattern.trim());
25
+ const full = path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(baseDir, expanded);
26
+ if (allowedRoot && !isWithinDir(full, allowedRoot)) {
27
+ return [];
28
+ }
13
29
  if (full.includes('*') || full.includes('?')) {
14
30
  return fs.globSync(full, { windowsPathsNoEscape: true })
15
31
  .filter((file) => {
16
- try { return fs.statSync(file).isFile(); } catch { return false; }
32
+ try {
33
+ const absFile = path.resolve(file);
34
+ if (allowedRoot && !isWithinDir(absFile, allowedRoot)) return false;
35
+ return fs.statSync(absFile).isFile();
36
+ } catch {
37
+ return false;
38
+ }
17
39
  })
18
40
  .sort();
19
41
  }
20
- return fs.existsSync(full) ? [full] : [];
42
+ try {
43
+ if (fs.existsSync(full) && fs.statSync(full).isFile()) {
44
+ return [full];
45
+ }
46
+ } catch {
47
+ return [];
48
+ }
49
+ return [];
21
50
  }
22
51
 
52
+
23
53
  export class SshConfigParser {
24
54
  /**
25
55
  * @param {string} text
@@ -31,7 +61,8 @@ export class SshConfigParser {
31
61
 
32
62
  static parseReport(text, options = {}) {
33
63
  const baseDir = options.baseDir || path.join(os.homedir(), '.ssh');
34
- const expandInclude = options.expandInclude || ((pattern) => defaultExpandInclude(pattern, baseDir));
64
+ const allowedRoot = options.allowedRoot !== undefined ? options.allowedRoot : (options.expandInclude ? null : baseDir);
65
+ const expandInclude = options.expandInclude || ((pattern) => defaultExpandInclude(pattern, baseDir, allowedRoot));
35
66
  const readFile = options.readFile || ((file) => fs.readFileSync(file, 'utf8'));
36
67
  const seen = options.seen || new Set();
37
68
  const known = options.knownIds || new Set();
@@ -71,6 +102,10 @@ export class SshConfigParser {
71
102
  }
72
103
  for (const file of files) {
73
104
  const abs = path.resolve(file);
105
+ if (allowedRoot && !isWithinDir(abs, allowedRoot)) {
106
+ skipped.push({ name: abs, reason: 'unauthorized-include' });
107
+ continue;
108
+ }
74
109
  if (seen.has(abs)) {
75
110
  skipped.push({ name: abs, reason: 'duplicate' });
76
111
  continue;
@@ -83,6 +118,7 @@ export class SshConfigParser {
83
118
  }
84
119
  const child = this.parseReport(nested, {
85
120
  baseDir: path.dirname(abs),
121
+ allowedRoot,
86
122
  expandInclude: options.expandInclude,
87
123
  readFile,
88
124
  seen,
@@ -137,7 +173,14 @@ export class SshConfigParser {
137
173
  current.authType = 'key';
138
174
  current.privateKey = val;
139
175
  } else if (key === 'proxyjump') current.jumpHostId = val;
140
- else if (key === 'proxycommand') current.proxyCommand = val;
176
+ else if (key === 'proxycommand') {
177
+ try {
178
+ validateProxyCommandTemplate(val);
179
+ current.proxyCommand = val;
180
+ } catch {
181
+ skipped.push({ block: current.name || 'unnamed', reason: 'ProxyCommand contains forbidden shell metacharacters' });
182
+ }
183
+ }
141
184
  }
142
185
  pushCurrent();
143
186
  return { profiles, skipped };
@@ -364,6 +364,7 @@ export class SshService {
364
364
  let pty = false;
365
365
  let timeoutMs = 0;
366
366
  let cleanAnsi = false;
367
+ let maxBufferSize = 10 * 1024 * 1024;
367
368
 
368
369
  if (typeof optionsOrCwd === 'string') {
369
370
  cwd = optionsOrCwd;
@@ -373,6 +374,11 @@ export class SshService {
373
374
  pty = Boolean(optionsOrCwd.pty);
374
375
  timeoutMs = optionsOrCwd.timeout ? optionsOrCwd.timeout * 1000 : 0;
375
376
  cleanAnsi = Boolean(optionsOrCwd.cleanAnsi);
377
+ if (typeof optionsOrCwd.maxBufferSize === 'number') {
378
+ maxBufferSize = optionsOrCwd.maxBufferSize;
379
+ } else if (typeof optionsOrCwd.maxBuffer === 'number') {
380
+ maxBufferSize = optionsOrCwd.maxBuffer;
381
+ }
376
382
  }
377
383
 
378
384
  this.bumpInFlight(profile.id, 1);
@@ -405,6 +411,7 @@ export class SshService {
405
411
  }
406
412
  let stdout = '';
407
413
  let stderr = '';
414
+ let truncated = false;
408
415
 
409
416
  if (timeoutMs > 0) {
410
417
  timer = setTimeout(() => {
@@ -423,13 +430,29 @@ export class SshService {
423
430
 
424
431
  stream.on('data', (data) => {
425
432
  const str = data.toString();
426
- stdout += str;
433
+ if (stdout.length < maxBufferSize) {
434
+ const remaining = maxBufferSize - stdout.length;
435
+ stdout += str.length > remaining ? str.slice(0, remaining) : str;
436
+ if (str.length > remaining) {
437
+ truncated = true;
438
+ stdout += '\n[Output truncated: exceeded max buffer size]';
439
+ try { stream.signal('SIGTERM'); stream.close(); } catch (_err) { /* ignore best-effort process termination */ }
440
+ }
441
+ }
427
442
  if (onData) onData({ type: 'stdout', data: str });
428
443
  });
429
444
 
430
445
  stream.stderr?.on('data', (data) => {
431
446
  const str = data.toString();
432
- stderr += str;
447
+ if (stderr.length < maxBufferSize) {
448
+ const remaining = maxBufferSize - stderr.length;
449
+ stderr += str.length > remaining ? str.slice(0, remaining) : str;
450
+ if (str.length > remaining) {
451
+ truncated = true;
452
+ stderr += '\n[Stderr truncated: exceeded max buffer size]';
453
+ try { stream.signal('SIGTERM'); stream.close(); } catch (_err) { /* ignore best-effort process termination */ }
454
+ }
455
+ }
433
456
  if (onData) onData({ type: 'stderr', data: str });
434
457
  });
435
458
 
@@ -440,7 +463,8 @@ export class SshService {
440
463
  code: code ?? 0,
441
464
  signal,
442
465
  stdout: cleanAnsi ? stripAnsi(stdout) : stdout,
443
- stderr: cleanAnsi ? stripAnsi(stderr) : stderr
466
+ stderr: cleanAnsi ? stripAnsi(stderr) : stderr,
467
+ truncated
444
468
  });
445
469
  });
446
470
  });
@@ -40,7 +40,7 @@ export class TarSyncService {
40
40
 
41
41
  localTar.on('close', (code) => {
42
42
  if (code !== 0) {
43
- try { stream.destroy(); } catch (_) {}
43
+ try { stream.destroy(); } catch (_err) { /* ignore best-effort stream cleanup */ }
44
44
  reject(new Error(`Local tar extraction failed (code ${code}): ${stderr}`));
45
45
  } else {
46
46
  resolve({ ok: true, direction: 'pull', remoteDir, localDir });
@@ -48,17 +48,17 @@ export class TarSyncService {
48
48
  });
49
49
 
50
50
  localTar.on('error', (err) => {
51
- try { stream.destroy(); } catch (_) {}
51
+ try { stream.destroy(); } catch (_err) { /* ignore best-effort stream cleanup */ }
52
52
  reject(err);
53
53
  });
54
54
 
55
55
  stream.on('error', (err) => {
56
- try { localTar.kill('SIGTERM'); } catch (_) {}
56
+ try { localTar.kill('SIGTERM'); } catch (_err) { /* ignore best-effort process termination */ }
57
57
  reject(err);
58
58
  });
59
59
 
60
60
  localTar.stdin.on('error', (err) => {
61
- try { stream.destroy(); } catch (_) {}
61
+ try { stream.destroy(); } catch (_err) { /* ignore best-effort stream cleanup */ }
62
62
  reject(err);
63
63
  });
64
64
  });
@@ -93,7 +93,7 @@ export class TarSyncService {
93
93
 
94
94
  stream.on('close', (code) => {
95
95
  if (code !== 0) {
96
- try { localTar.kill('SIGTERM'); } catch (_) {}
96
+ try { localTar.kill('SIGTERM'); } catch (_err) { /* ignore best-effort process termination */ }
97
97
  reject(new Error(`Remote tar extraction failed (code ${code}): ${remoteStderr}`));
98
98
  } else {
99
99
  resolve({ ok: true, direction: 'push', localDir, remoteDir });
@@ -101,12 +101,12 @@ export class TarSyncService {
101
101
  });
102
102
 
103
103
  stream.on('error', (err) => {
104
- try { localTar.kill('SIGTERM'); } catch (_) {}
104
+ try { localTar.kill('SIGTERM'); } catch (_err) { /* ignore best-effort process termination */ }
105
105
  reject(err);
106
106
  });
107
107
 
108
108
  localTar.on('error', (err) => {
109
- try { stream.destroy(); } catch (_) {}
109
+ try { stream.destroy(); } catch (_err) { /* ignore best-effort stream cleanup */ }
110
110
  reject(err);
111
111
  });
112
112
  });