@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/README.md +9 -4
- package/SECURITY.md +21 -0
- package/package.json +13 -16
- package/src/cli.js +33 -3
- package/src/lib/ai/safety.js +69 -12
- package/src/lib/broker.js +1 -1
- package/src/lib/chat.js +13 -5
- package/src/lib/cleanup.js +60 -93
- package/src/lib/open-url.js +28 -0
- package/src/lib/platform.js +38 -481
- package/src/lib/socket-firewall.js +34 -0
- package/src/lib/ssh.js +7 -1
- package/src/lib/uid.js +6 -3
- package/src/modes/ai.js +102 -26
- package/src/modes/client.js +12 -3
- package/src/modes/host.js +168 -99
- package/src/server.js +5 -2
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
|
|
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: '
|
|
169
|
-
description: '
|
|
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
|
|
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
|
-
|
|
258
|
-
const
|
|
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(
|
|
316
|
+
return redactSensitive(fs.readFileSync(resolvedPath, 'utf8'));
|
|
262
317
|
}
|
|
263
318
|
|
|
264
319
|
async function readRemoteTextFile(context, filePath) {
|
|
265
|
-
|
|
266
|
-
|
|
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);
|
package/src/modes/client.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
723
|
+
await openUrl(fullUrl);
|
|
715
724
|
} catch {
|
|
716
725
|
console.log(chalk.cyan(` 👉 Please open: ${secureSensitiveUrl(chatUrl, password)}`));
|
|
717
726
|
}
|