@miraj181/ipingyou 2.1.9 → 2.1.18

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.
@@ -6,13 +6,47 @@ import { redactSensitive } from './ai/safety.js';
6
6
  const LOG_DIR = path.join(os.homedir(), '.ipingyou', 'logs');
7
7
  const LOG_FILE = path.join(LOG_DIR, 'session-events.jsonl');
8
8
  const SESSION_LOG_DIR = path.join(os.tmpdir(), 'ipingyou-session-logs');
9
+ const MAX_HISTORY_LOG_BYTES = 5 * 1024 * 1024;
10
+ const MAX_SESSION_LOG_BYTES = 2 * 1024 * 1024;
11
+ const MAX_LOG_STRING_LENGTH = 16 * 1024;
12
+ const SESSION_LOG_FLUSH_BYTES = 64 * 1024;
13
+ const SESSION_LOG_FLUSH_MS = 250;
9
14
 
10
15
  let sessionLogPath = null;
11
16
  let sessionLogDisabled = false;
12
17
  let cleanupRegistered = false;
18
+ let sessionLogBytes = 0;
19
+ let historyLogBytes = null;
20
+ let sessionLogBuffer = '';
21
+ let sessionLogFlushTimer = null;
22
+
23
+ function flushSessionLog() {
24
+ if (sessionLogFlushTimer) clearTimeout(sessionLogFlushTimer);
25
+ sessionLogFlushTimer = null;
26
+ if (!sessionLogPath || !sessionLogBuffer) return;
27
+ const buffered = sessionLogBuffer;
28
+ sessionLogBuffer = '';
29
+ try {
30
+ fs.appendFileSync(sessionLogPath, buffered, { mode: 0o600 });
31
+ } catch (err) {
32
+ sessionLogDisabled = true;
33
+ console.error(`Session log write failed: ${err.message}`);
34
+ }
35
+ }
36
+
37
+ function scheduleSessionLogFlush() {
38
+ if (sessionLogFlushTimer) return;
39
+ sessionLogFlushTimer = setTimeout(flushSessionLog, SESSION_LOG_FLUSH_MS);
40
+ sessionLogFlushTimer.unref?.();
41
+ }
13
42
 
14
43
  function sanitize(value) {
15
- if (typeof value === 'string') return redactSensitive(value);
44
+ if (typeof value === 'string') {
45
+ const redacted = redactSensitive(value);
46
+ return redacted.length > MAX_LOG_STRING_LENGTH
47
+ ? `${redacted.slice(0, MAX_LOG_STRING_LENGTH)}…[truncated]`
48
+ : redacted;
49
+ }
16
50
  if (Array.isArray(value)) return value.map(sanitize);
17
51
  if (value && typeof value === 'object') {
18
52
  return Object.fromEntries(
@@ -28,11 +62,23 @@ export function initSessionLog(scope = 'session') {
28
62
  if (sessionLogPath || sessionLogDisabled) return sessionLogPath;
29
63
  try {
30
64
  fs.mkdirSync(SESSION_LOG_DIR, { recursive: true, mode: 0o700 });
65
+ const staleBefore = Date.now() - (24 * 60 * 60 * 1000);
66
+ for (const name of fs.readdirSync(SESSION_LOG_DIR)) {
67
+ if (!name.startsWith('ipingyou-')) continue;
68
+ const candidate = path.join(SESSION_LOG_DIR, name);
69
+ try {
70
+ if (fs.statSync(candidate).mtimeMs < staleBefore) fs.unlinkSync(candidate);
71
+ } catch {
72
+ // Best-effort cleanup; another process may own or remove the file.
73
+ }
74
+ }
31
75
  sessionLogPath = path.join(
32
76
  SESSION_LOG_DIR,
33
77
  `ipingyou-${scope}-${Date.now()}-${process.pid}.log`
34
78
  );
35
79
  fs.writeFileSync(sessionLogPath, '', { mode: 0o600 });
80
+ sessionLogBytes = 0;
81
+ sessionLogBuffer = '';
36
82
  if (!cleanupRegistered) {
37
83
  process.on('exit', () => cleanupSessionLog());
38
84
  cleanupRegistered = true;
@@ -59,7 +105,19 @@ export function logSessionEvent(type, details = {}, level = 'info') {
59
105
  details: sanitize(details),
60
106
  };
61
107
  try {
62
- fs.appendFileSync(sessionLogPath, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
108
+ const line = `${JSON.stringify(entry)}\n`;
109
+ const lineBytes = Buffer.byteLength(line);
110
+ if (sessionLogBytes + lineBytes > MAX_SESSION_LOG_BYTES) {
111
+ sessionLogDisabled = true;
112
+ return;
113
+ }
114
+ sessionLogBuffer += line;
115
+ sessionLogBytes += lineBytes;
116
+ if (Buffer.byteLength(sessionLogBuffer) >= SESSION_LOG_FLUSH_BYTES) {
117
+ flushSessionLog();
118
+ } else {
119
+ scheduleSessionLogFlush();
120
+ }
63
121
  } catch (err) {
64
122
  sessionLogDisabled = true;
65
123
  console.error(`Session log write failed: ${err.message}`);
@@ -68,6 +126,7 @@ export function logSessionEvent(type, details = {}, level = 'info') {
68
126
 
69
127
  export function cleanupSessionLog() {
70
128
  if (!sessionLogPath) return;
129
+ flushSessionLog();
71
130
  const target = sessionLogPath;
72
131
  sessionLogPath = null;
73
132
  try {
@@ -85,7 +144,23 @@ export function recordEvent(type, details = {}) {
85
144
  type,
86
145
  details: sanitize(details),
87
146
  };
88
- fs.appendFileSync(LOG_FILE, `${JSON.stringify(event)}\n`, { mode: 0o600 });
147
+ const line = `${JSON.stringify(event)}\n`;
148
+ const lineBytes = Buffer.byteLength(line);
149
+ if (historyLogBytes === null) {
150
+ historyLogBytes = fs.existsSync(LOG_FILE) ? fs.statSync(LOG_FILE).size : 0;
151
+ }
152
+ if (historyLogBytes + lineBytes > MAX_HISTORY_LOG_BYTES) {
153
+ const previousLog = `${LOG_FILE}.1`;
154
+ try {
155
+ if (fs.existsSync(previousLog)) fs.unlinkSync(previousLog);
156
+ if (fs.existsSync(LOG_FILE)) fs.renameSync(LOG_FILE, previousLog);
157
+ } catch {
158
+ fs.writeFileSync(LOG_FILE, '', { mode: 0o600 });
159
+ }
160
+ historyLogBytes = 0;
161
+ }
162
+ fs.appendFileSync(LOG_FILE, line, { mode: 0o600 });
163
+ historyLogBytes += lineBytes;
89
164
  logSessionEvent(type, details);
90
165
  } catch {
91
166
  // Session recording is best-effort.
@@ -0,0 +1,34 @@
1
+ import { execa } from 'execa';
2
+
3
+ export async function getSocketFirewallStatus() {
4
+ try {
5
+ const result = await execa('sfw', ['--version'], {
6
+ reject: false,
7
+ timeout: 15000,
8
+ maxBuffer: 64 * 1024,
9
+ env: { ...process.env, SFW_SKIP_UPDATE_CHECK: '1' },
10
+ });
11
+ return {
12
+ available: result.exitCode === 0,
13
+ version: String(result.stdout || result.stderr || '').trim() || null,
14
+ };
15
+ } catch {
16
+ return { available: false, version: null };
17
+ }
18
+ }
19
+
20
+ export async function runProtectedNpmInstall(args, options = {}) {
21
+ const status = await getSocketFirewallStatus();
22
+ if (!status.available) {
23
+ throw new Error(
24
+ 'Socket Firewall is required for package installation. '
25
+ + 'Install it first with: npm install -g sfw'
26
+ );
27
+ }
28
+
29
+ return execa('sfw', ['npm', 'install', ...args], {
30
+ ...options,
31
+ reject: true,
32
+ env: { ...process.env, ...options.env },
33
+ });
34
+ }
package/src/lib/ssh.js CHANGED
@@ -1,11 +1,26 @@
1
1
  import crypto from 'node:crypto';
2
2
 
3
+ const SAFE_HOSTNAME_PATTERN = /^(?=.{1,253}$)(?!.*\.\.)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i;
4
+
5
+ export function assertSafeHostname(hostname, label = 'hostname') {
6
+ const normalized = String(hostname || '').trim().replace(/\.$/, '').toLowerCase();
7
+ if (!normalized || !SAFE_HOSTNAME_PATTERN.test(normalized)) {
8
+ throw new Error(`Invalid ${label}`);
9
+ }
10
+ return normalized;
11
+ }
12
+
3
13
  export function extractHostname(url) {
14
+ let parsed;
4
15
  try {
5
- return new URL(url).hostname;
16
+ parsed = new URL(url);
6
17
  } catch {
7
- return url.replace(/^https?:\/\//, '').replace(/\/$/, '');
18
+ throw new Error('Invalid tunnel URL');
19
+ }
20
+ if (parsed.protocol !== 'https:') {
21
+ throw new Error('Tunnel URL must use HTTPS');
8
22
  }
23
+ return assertSafeHostname(parsed.hostname, 'tunnel hostname');
9
24
  }
10
25
 
11
26
  export function quoteRemoteShell(value) {
@@ -21,12 +36,19 @@ export function formatRemoteCd(remotePath) {
21
36
  export function formatScpRemotePath(remotePath) {
22
37
  const trimmed = String(remotePath || '').trim();
23
38
  if (!trimmed || trimmed === '~') return trimmed || '~';
24
- return trimmed;
39
+ if (/[\u0000\r\n]/.test(trimmed) || trimmed.length > 4096) {
40
+ throw new Error('Invalid remote path');
41
+ }
42
+ if (trimmed.startsWith('~/')) {
43
+ return `~/${quoteRemoteShell(trimmed.slice(2))}`;
44
+ }
45
+ return quoteRemoteShell(trimmed);
25
46
  }
26
47
 
27
48
  export function getSshControlOptions(hostname) {
49
+ const safeHostname = assertSafeHostname(hostname, 'ssh hostname');
28
50
  if (process.platform === 'win32') return [];
29
- const hash = crypto.createHash('sha256').update(hostname).digest('hex').slice(0, 10);
51
+ const hash = crypto.createHash('sha256').update(safeHostname).digest('hex').slice(0, 10);
30
52
  return [
31
53
  '-o', 'ControlMaster=auto',
32
54
  '-o', 'ControlPersist=5m',
@@ -34,6 +56,11 @@ export function getSshControlOptions(hostname) {
34
56
  ];
35
57
  }
36
58
 
59
+ export function buildProxyCommandOption(hostname) {
60
+ const safeHostname = assertSafeHostname(hostname, 'tunnel hostname');
61
+ return ['-o', `ProxyCommand=cloudflared access tcp --hostname ${safeHostname}`];
62
+ }
63
+
37
64
  export function getKnownHostsOptions(persistKnownHosts = true) {
38
65
  if (persistKnownHosts) {
39
66
  return ['-o', 'StrictHostKeyChecking=accept-new'];
@@ -48,9 +75,8 @@ export function getKnownHostsOptions(persistKnownHosts = true) {
48
75
 
49
76
  export function buildSshArgs(hostname, privateKeyPath, extraOptions = [], options = {}) {
50
77
  const { persistKnownHosts = true } = options;
51
- const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
52
78
  const sshArgs = [
53
- '-o', `ProxyCommand=${proxyCommand}`,
79
+ ...buildProxyCommandOption(hostname),
54
80
  ...getKnownHostsOptions(persistKnownHosts),
55
81
  '-o', 'IdentitiesOnly=yes',
56
82
  ...getSshControlOptions(hostname),
package/src/lib/tunnel.js CHANGED
@@ -15,6 +15,7 @@ export async function spawnTunnelSupervised(targetUrl, onUrlGenerated) {
15
15
  activeChild = execa('cloudflared', ['tunnel', '--url', targetUrl], {
16
16
  reject: false,
17
17
  all: true,
18
+ buffer: false,
18
19
  });
19
20
 
20
21
  trackPID(activeChild.pid);
package/src/lib/uid.js CHANGED
@@ -8,16 +8,19 @@
8
8
  * ============================================================
9
9
  */
10
10
 
11
- import { customAlphabet } from 'nanoid';
11
+ import crypto from 'node:crypto';
12
12
 
13
13
  // Use lowercase alphanumeric only — easy to share verbally
14
14
  const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
15
- const generate = customAlphabet(alphabet, 8);
16
15
 
17
16
  /**
18
17
  * Generate a random 8-character session UID.
19
18
  * @returns {string}
20
19
  */
21
20
  export function generateUID() {
22
- return generate();
21
+ let uid = '';
22
+ for (let index = 0; index < 8; index += 1) {
23
+ uid += alphabet[crypto.randomInt(0, alphabet.length)];
24
+ }
25
+ return uid;
23
26
  }
@@ -0,0 +1,81 @@
1
+ import { Worker } from 'node:worker_threads';
2
+
3
+ const workerUrl = new URL('./workers/crypto-checksum-worker.js', import.meta.url);
4
+ const workersDisabled = process.env.IPINGYOU_DISABLE_WORKERS === '1';
5
+ const MAX_PENDING_TASKS = 128;
6
+
7
+ let worker = null;
8
+ let requestCounter = 0;
9
+ const pending = new Map();
10
+
11
+ function resetWorker(err) {
12
+ for (const { reject } of pending.values()) {
13
+ reject(err);
14
+ }
15
+ pending.clear();
16
+ worker = null;
17
+ }
18
+
19
+ function ensureWorker() {
20
+ if (worker || workersDisabled) return worker;
21
+
22
+ worker = new Worker(workerUrl, {
23
+ resourceLimits: {
24
+ maxOldGenerationSizeMb: 128,
25
+ },
26
+ });
27
+ worker.unref();
28
+
29
+ worker.on('message', (message) => {
30
+ const { id, ok, result, error } = message || {};
31
+ const entry = pending.get(id);
32
+ if (!entry) return;
33
+ pending.delete(id);
34
+ if (pending.size === 0) worker?.unref();
35
+ if (ok) {
36
+ entry.resolve(result);
37
+ return;
38
+ }
39
+ entry.reject(new Error(error || 'Worker task failed'));
40
+ });
41
+
42
+ worker.on('error', (err) => {
43
+ resetWorker(err);
44
+ });
45
+
46
+ worker.on('exit', (code) => {
47
+ if (code !== 0) {
48
+ resetWorker(new Error(`Worker exited with code ${code}`));
49
+ } else {
50
+ worker = null;
51
+ }
52
+ });
53
+
54
+ return worker;
55
+ }
56
+
57
+ export function canUseWorkers() {
58
+ return !workersDisabled;
59
+ }
60
+
61
+ export function runWorkerTask(type, payload) {
62
+ if (!canUseWorkers()) {
63
+ throw new Error('Worker threads are disabled');
64
+ }
65
+ const activeWorker = ensureWorker();
66
+ if (!activeWorker) {
67
+ throw new Error('Worker is not available');
68
+ }
69
+ if (pending.size >= MAX_PENDING_TASKS) {
70
+ const error = new Error('Worker task queue is at capacity');
71
+ error.code = 'WORKER_QUEUE_FULL';
72
+ throw error;
73
+ }
74
+
75
+ const id = ++requestCounter;
76
+ return new Promise((resolve, reject) => {
77
+ pending.set(id, { resolve, reject });
78
+ activeWorker.ref();
79
+ activeWorker.postMessage({ id, type, payload });
80
+ });
81
+ }
@@ -0,0 +1,70 @@
1
+ import { parentPort } from 'node:worker_threads';
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+
5
+ function deriveKey(password, salt) {
6
+ return crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256');
7
+ }
8
+
9
+ function encryptPayload(plaintext, password) {
10
+ const salt = crypto.randomBytes(16);
11
+ const key = deriveKey(password, salt);
12
+ const iv = crypto.randomBytes(16);
13
+
14
+ const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
15
+ let enc = cipher.update(plaintext, 'utf8', 'base64');
16
+ enc += cipher.final('base64');
17
+
18
+ return {
19
+ iv: iv.toString('hex'),
20
+ ciphertext: enc,
21
+ salt: salt.toString('hex'),
22
+ };
23
+ }
24
+
25
+ function decryptPayload(ivHex, cipherBase64, password, saltHex) {
26
+ const salt = Buffer.from(saltHex, 'hex');
27
+ const key = deriveKey(password, salt);
28
+ const iv = Buffer.from(ivHex, 'hex');
29
+ const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
30
+ let dec = decipher.update(cipherBase64, 'base64', 'utf8');
31
+ dec += decipher.final('utf8');
32
+ return dec;
33
+ }
34
+
35
+ function checksumFile(filePath) {
36
+ return new Promise((resolve, reject) => {
37
+ const hash = crypto.createHash('sha256');
38
+ const stream = fs.createReadStream(filePath);
39
+ stream.on('error', reject);
40
+ stream.on('data', (chunk) => hash.update(chunk));
41
+ stream.on('end', () => resolve(hash.digest('hex')));
42
+ });
43
+ }
44
+
45
+ async function executeTask(type, payload) {
46
+ switch (type) {
47
+ case 'encrypt':
48
+ return encryptPayload(payload.plaintext, payload.password);
49
+ case 'decrypt':
50
+ return {
51
+ plaintext: decryptPayload(payload.ivHex, payload.cipherBase64, payload.password, payload.saltHex),
52
+ };
53
+ case 'checksum':
54
+ return {
55
+ digest: await checksumFile(payload.filePath),
56
+ };
57
+ default:
58
+ throw new Error(`Unsupported worker task: ${type}`);
59
+ }
60
+ }
61
+
62
+ parentPort.on('message', async (message) => {
63
+ const { id, type, payload } = message || {};
64
+ try {
65
+ const result = await executeTask(type, payload || {});
66
+ parentPort.postMessage({ id, ok: true, result });
67
+ } catch (err) {
68
+ parentPort.postMessage({ id, ok: false, error: err.message });
69
+ }
70
+ });
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);
@@ -574,12 +650,10 @@ async function tryAITransfer(task, context) {
574
650
  if (context && context.scope === 'remote' && context.hostname && context.username) {
575
651
  console.log(chalk.dim(` Using active remote session: ${context.username}@${context.hostname}`));
576
652
 
577
- const { getSshControlOptions, formatScpRemotePath } = await import('../lib/ssh.js');
578
-
579
- const proxyCommand = `cloudflared access tcp --hostname ${context.hostname}`;
653
+ const { buildProxyCommandOption, getSshControlOptions, formatScpRemotePath } = await import('../lib/ssh.js');
580
654
  const scpArgs = [
581
655
  '-r',
582
- '-o', `ProxyCommand=${proxyCommand}`,
656
+ ...buildProxyCommandOption(context.hostname),
583
657
  '-o', 'StrictHostKeyChecking=accept-new',
584
658
  '-o', 'IdentitiesOnly=yes',
585
659
  ...getSshControlOptions(context.hostname),
@@ -630,4 +704,3 @@ async function tryAITransfer(task, context) {
630
704
  return true;
631
705
  }
632
706
  }
633
-