@miraj181/ipingyou 2.1.15 → 2.1.19

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/src/modes/ai.js CHANGED
@@ -3,7 +3,6 @@
3
3
  */
4
4
 
5
5
  import { execa } from 'execa';
6
- import { parse as shellParse } from 'shell-quote';
7
6
  import chalk from 'chalk';
8
7
  import inquirer from 'inquirer';
9
8
  import fs from 'node:fs';
@@ -11,13 +10,13 @@ import os from 'node:os';
11
10
  import path from 'node:path';
12
11
  import { getAlias } from '../lib/config.js';
13
12
  import { resolveUID } from '../lib/broker.js';
14
- import { buildSshArgs, extractHostname } from '../lib/ssh.js';
13
+ import { buildSshArgs, extractHostname, quoteRemoteShell } from '../lib/ssh.js';
15
14
  import { addCleanupHook, cleanupAll } from '../lib/cleanup.js';
16
15
  import { startHostMode } from './host.js';
17
16
  import { startClientMode } from './client.js';
18
17
  import { performSCPNonInteractive } from './client.js';
19
18
  import { DEFAULT_AI_MODEL, createGroqChatCompletion, getGroqApiKey, getRateLimitWarnings, listGroqModels, estimateTokensForMessages } from '../lib/ai/groq.js';
20
- import { classifyCommand, redactSensitive, sanitizeUserTask, truncateForModel } from '../lib/ai/safety.js';
19
+ import { assertSafeReadablePath, classifyCommand, redactSensitive, sanitizeUserTask, truncateForModel } from '../lib/ai/safety.js';
21
20
  import { recordEvent } from '../lib/session-log.js';
22
21
 
23
22
  let BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
@@ -139,13 +138,8 @@ async function confirmCommand(scope, command, reason, classification) {
139
138
  return false;
140
139
  }
141
140
 
142
- if (!classification.needsApproval) {
143
- console.log(chalk.dim(` AI tool: ${scope} $ ${command}`));
144
- return true;
145
- }
146
-
147
141
  console.log('');
148
- console.log(chalk.yellow(' AI wants to run a command that needs approval:'));
142
+ console.log(chalk.yellow(' AI wants to run a command:'));
149
143
  console.log(chalk.dim(` Scope: ${scope}`));
150
144
  console.log(chalk.dim(` Reason: ${reason || classification.reason}`));
151
145
  console.log(chalk.cyan(` ${command}`));
@@ -160,13 +154,27 @@ async function confirmCommand(scope, command, reason, classification) {
160
154
  return allow;
161
155
  }
162
156
 
157
+ async function confirmFileRead(scope, filePath) {
158
+ console.log('');
159
+ console.log(chalk.yellow(' AI wants to read a text file and send redacted content to Groq:'));
160
+ console.log(chalk.dim(` Scope: ${scope}`));
161
+ console.log(chalk.cyan(` ${filePath}`));
162
+ const { allow } = await inquirer.prompt([{
163
+ type: 'confirm',
164
+ name: 'allow',
165
+ message: 'Allow this file read?',
166
+ default: false,
167
+ }]);
168
+ return allow;
169
+ }
170
+
163
171
  function matchAppAction(task) {
164
172
  const lowered = String(task || '').toLowerCase();
165
173
  if (/\b(panic|self[- ]?destruct|wipe traces)\b/i.test(lowered)) {
166
174
  return {
167
175
  id: 'panic_blocked',
168
- label: 'Panic mode',
169
- description: 'Panic mode is intentionally not launched from AI mode. Run `ipingyou panic` directly if you mean it.',
176
+ label: 'Emergency shutdown',
177
+ description: 'Emergency shutdown is never launched from AI mode. Run `ipingyou panic` directly and confirm it locally.',
170
178
  blocked: true,
171
179
  };
172
180
  }
@@ -211,9 +219,7 @@ function showRateLimitWarnings(rateLimit) {
211
219
  }
212
220
 
213
221
  async function runLocalCommand(command) {
214
- const parsed = shellParse(command);
215
- // Filter out non-string tokens (shell operators like |, &&, ; etc.) to prevent injection
216
- const args = parsed.filter(token => typeof token === 'string');
222
+ const args = parseLocalCommand(command);
217
223
  if (args.length === 0) {
218
224
  return { exitCode: 1, stdout: '', stderr: 'Empty or unsafe command after parsing' };
219
225
  }
@@ -230,6 +236,57 @@ async function runLocalCommand(command) {
230
236
  };
231
237
  }
232
238
 
239
+ export function parseLocalCommand(command) {
240
+ const input = String(command || '');
241
+ if (!input || input.length > 8192 || /[\u0000\r\n]/.test(input)) {
242
+ throw new Error('Command is empty, too long, or contains control characters');
243
+ }
244
+
245
+ const args = [];
246
+ let current = '';
247
+ let quote = null;
248
+ let tokenStarted = false;
249
+
250
+ for (let index = 0; index < input.length; index += 1) {
251
+ const char = input[index];
252
+ if (quote) {
253
+ if (char === quote) {
254
+ quote = null;
255
+ } else if (char === '\\' && quote === '"' && index + 1 < input.length) {
256
+ current += input[++index];
257
+ } else {
258
+ current += char;
259
+ }
260
+ tokenStarted = true;
261
+ continue;
262
+ }
263
+
264
+ if (char === "'" || char === '"') {
265
+ quote = char;
266
+ tokenStarted = true;
267
+ } else if (/\s/.test(char)) {
268
+ if (tokenStarted) {
269
+ args.push(current);
270
+ current = '';
271
+ tokenStarted = false;
272
+ }
273
+ } else if (char === '\\') {
274
+ if (index + 1 >= input.length) throw new Error('Command ends with an incomplete escape');
275
+ current += input[++index];
276
+ tokenStarted = true;
277
+ } else if (/[;&|`$()<>]/.test(char)) {
278
+ throw new Error('Local AI commands do not support shell operators');
279
+ } else {
280
+ current += char;
281
+ tokenStarted = true;
282
+ }
283
+ }
284
+
285
+ if (quote) throw new Error('Command contains an unterminated quote');
286
+ if (tokenStarted) args.push(current);
287
+ return args;
288
+ }
289
+
233
290
  async function runRemoteCommand(context, command) {
234
291
  const sshArgs = buildSshArgs(context.hostname, context.privateKeyPath);
235
292
  sshArgs.push(`${context.username}@${context.hostname}`, command);
@@ -246,24 +303,34 @@ async function runRemoteCommand(context, command) {
246
303
  };
247
304
  }
248
305
 
249
- function assertReadablePath(filePath) {
250
- const classification = classifyCommand(`cat ${filePath}`);
251
- if (classification.blocked) {
252
- throw new Error(classification.reason);
253
- }
254
- }
255
-
256
306
  async function readLocalTextFile(filePath) {
257
- assertReadablePath(filePath);
258
- const stat = fs.statSync(filePath);
307
+ const requestedPath = assertSafeReadablePath(filePath);
308
+ const expandedPath = requestedPath === '~'
309
+ ? os.homedir()
310
+ : requestedPath.replace(/^~(?=[/\\])/, os.homedir());
311
+ const resolvedPath = fs.realpathSync(path.resolve(expandedPath));
312
+ assertSafeReadablePath(resolvedPath);
313
+ const stat = fs.statSync(resolvedPath);
259
314
  if (!stat.isFile()) throw new Error('Path is not a file');
260
315
  if (stat.size > 256 * 1024) throw new Error('File is too large for AI mode; use a targeted command instead');
261
- return redactSensitive(fs.readFileSync(filePath, 'utf8'));
316
+ return redactSensitive(fs.readFileSync(resolvedPath, 'utf8'));
262
317
  }
263
318
 
264
319
  async function readRemoteTextFile(context, filePath) {
265
- assertReadablePath(filePath);
266
- return runRemoteCommand(context, `python3 - <<'PY'\nfrom pathlib import Path\np=Path(${JSON.stringify(filePath)})\nif not p.is_file(): raise SystemExit('Path is not a file')\nif p.stat().st_size > 262144: raise SystemExit('File is too large for AI mode')\nprint(p.read_text(errors='replace'), end='')\nPY`);
320
+ const safePath = assertSafeReadablePath(filePath);
321
+ const script = [
322
+ 'import pathlib, sys',
323
+ 'p = pathlib.Path(sys.argv[1]).expanduser().resolve()',
324
+ 'parts = {part.lower() for part in p.parts}',
325
+ "blocked = {'.ssh','.gnupg','.aws','.ipingyou','.kube','.docker'}",
326
+ "name = p.name.lower()",
327
+ "if parts & blocked or name in {'.npmrc','.netrc','.pypirc','credentials','credentials.json','known_hosts','authorized_keys','shadow','sudoers'} or name.startswith('.env') or name in {'id_rsa','id_dsa','id_ecdsa','id_ed25519'}: raise SystemExit('Protected path')",
328
+ "if not p.is_file(): raise SystemExit('Path is not a file')",
329
+ "if p.stat().st_size > 262144: raise SystemExit('File is too large for AI mode')",
330
+ "sys.stdout.write(p.read_text(errors='replace'))",
331
+ ].join('\n');
332
+ const command = `python3 -c ${quoteRemoteShell(script)} -- ${quoteRemoteShell(safePath)}`;
333
+ return runRemoteCommand(context, command);
267
334
  }
268
335
 
269
336
  async function setupRemoteContext() {
@@ -391,6 +458,15 @@ async function executeToolCall(context, call) {
391
458
  if (name === 'read_text_file') {
392
459
  const filePath = String(args.filePath || '').trim();
393
460
  if (!filePath) return buildToolResult({ ok: false, error: 'Missing filePath' });
461
+ try {
462
+ assertSafeReadablePath(filePath);
463
+ } catch (err) {
464
+ return buildToolResult({ ok: false, blocked: true, error: err.message });
465
+ }
466
+ if (!await confirmFileRead(context.scope, filePath)) {
467
+ return buildToolResult({ ok: false, blocked: true, error: 'User denied file access' });
468
+ }
469
+ recordEvent('ai_file_read_approved', { scope: context.scope });
394
470
 
395
471
  if (context.scope === 'remote') {
396
472
  const result = await readRemoteTextFile(context, filePath);
@@ -26,7 +26,7 @@ import { calculateChecksum } from '../lib/checksum.js';
26
26
  import { promptLocalPath, promptRemotePath } from '../lib/path-browser.js';
27
27
  import { buildProxyCommandOption, buildSshArgs, extractHostname, formatScpRemotePath, getKnownHostsOptions, getSshControlOptions, quoteRemoteShell } from '../lib/ssh.js';
28
28
  import { buildTmuxSessionName, TMUX_SOCKET_PATH } from '../lib/tmux.js';
29
- import open from 'open';
29
+ import { openUrl } from '../lib/open-url.js';
30
30
  import { secureSensitiveUrl } from '../lib/secure-print.js';
31
31
  import { cleanupSessionLog, initSessionLog, logSessionEvent, recordEvent } from '../lib/session-log.js';
32
32
 
@@ -54,6 +54,15 @@ async function writeEphemeralPrivateKey(privateKey) {
54
54
  const keyPath = path.join(os.tmpdir(), `ipingyou_client_${Date.now()}`);
55
55
  fs.writeFileSync(keyPath, normalizePrivateKey(privateKey), { mode: 0o600 });
56
56
 
57
+ // On Windows, NTFS ignores POSIX mode bits — fix ACLs with icacls
58
+ if (process.platform === 'win32') {
59
+ const currentUser = os.userInfo().username;
60
+ // Remove all inherited permissions first
61
+ await execa('icacls', [keyPath, '/inheritance:r'], { reject: false });
62
+ // Grant only the current user full control
63
+ await execa('icacls', [keyPath, '/grant:r', `${currentUser}:(F)`], { reject: false });
64
+ }
65
+
57
66
  const result = await execa('ssh-keygen', ['-y', '-f', keyPath], {
58
67
  reject: false,
59
68
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -479,7 +488,7 @@ export async function startClientMode(options = {}) {
479
488
  console.log('');
480
489
  logSessionEvent('client_http_mode', { uid: targetUid, port: payload.port || null });
481
490
  try {
482
- await open(payload.url);
491
+ await openUrl(payload.url);
483
492
  } catch {
484
493
  console.log(chalk.dim(` Could not auto-open. Please visit: ${payload.url}`));
485
494
  }
@@ -711,7 +720,7 @@ async function handleClientChat(uid, password, cachedChatUrl) {
711
720
  spinner.succeed('Chat Room found! Opening browser...');
712
721
  try {
713
722
  const fullUrl = `${chatUrl}#${password}`;
714
- await open(fullUrl);
723
+ await openUrl(fullUrl);
715
724
  } catch {
716
725
  console.log(chalk.cyan(` 👉 Please open: ${secureSensitiveUrl(chatUrl, password)}`));
717
726
  }