@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.
- package/README.md +9 -4
- package/SECURITY.md +21 -0
- package/package.json +13 -13
- package/src/cli.js +55 -22
- package/src/lib/ai/safety.js +69 -12
- package/src/lib/broker.js +5 -5
- package/src/lib/chat.js +20 -6
- package/src/lib/checksum.js +22 -2
- package/src/lib/cleanup.js +60 -93
- package/src/lib/crypto.js +27 -0
- package/src/lib/open-url.js +28 -0
- package/src/lib/platform.js +38 -481
- package/src/lib/secure-print.js +7 -1
- package/src/lib/session-log.js +78 -3
- package/src/lib/socket-firewall.js +34 -0
- package/src/lib/ssh.js +32 -6
- package/src/lib/tunnel.js +1 -0
- package/src/lib/uid.js +6 -3
- package/src/lib/worker-runtime.js +81 -0
- package/src/lib/workers/crypto-checksum-worker.js +70 -0
- package/src/modes/ai.js +104 -31
- package/src/modes/client.js +20 -13
- package/src/modes/host.js +316 -116
- package/src/server.js +95 -18
package/src/lib/session-log.js
CHANGED
|
@@ -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')
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16
|
+
parsed = new URL(url);
|
|
6
17
|
} catch {
|
|
7
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
79
|
+
...buildProxyCommandOption(hostname),
|
|
54
80
|
...getKnownHostsOptions(persistKnownHosts),
|
|
55
81
|
'-o', 'IdentitiesOnly=yes',
|
|
56
82
|
...getSshControlOptions(hostname),
|
package/src/lib/tunnel.js
CHANGED
package/src/lib/uid.js
CHANGED
|
@@ -8,16 +8,19 @@
|
|
|
8
8
|
* ============================================================
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import
|
|
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
|
-
|
|
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
|
|
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);
|
|
@@ -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
|
-
|
|
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
|
-
|